Java also uses a similar method under the hood for its serialization protocol, whereby the type is written before the data; But type alone is not the only consideration to make if you're talking about generic object storage and instantiation...
I've done something similar back in C99. When porting my OOP system to C++, and before the introduction of run-time type inspection to C++, I had included a type ENUM and a macro which when inserted into each new type would add a new entry to the OB_TYPE_ID enumeration, and assign that as the static ID of the type.
At the time I had created a scripting language with tight bindings to C which allowed C code to transparently read/write properties and call functions on script objects using some preprocessor and macro magic (rather than eval("script code") or similar as seen in Lua and other embedded scripting langs). In constructing my Object class (building custom OOP within C), I included the run-time type ID by default, and a registration system which allowed objects to register the default instance of themselves with a factory. Then factory->OB_Instantiate( dataptr ) would read a type ID from the dataptr, advance the pointer, look up the object registered for the ID, then call the (script provided) function Object->clone()->load( dataptr ); In addition to polymorphism this allows "prototypical" behavior since scripts could replace objects with new objects that provide more / new functionality via registering a compatible subclasses for a TypeID -- However, that form of dynamism is purely optional. Of course the gnarly script-required calling semantics in the host language were hidden behind a macro...
When registering a type with the factory, one also registered its property definition, of interest was the offset of pointers within the object data which could reference other objects that also needed to be saved or loaded. In the OB_Instantiate() function the property definition for the clonee would be queried to determine if there were any references to other objects that the object needed to load. It would then "recursively" (via iterative trampoline) load all of the objects referenced by this object. This allowed me to perform loading and saving of the entire game state via a single save( game ) or load( game );
One issue I ran into (but you may not, since my implementation was meant to serialize general purpose language objects rather than a specific set of pre-known classes), was that that several objects could reference the same other object. This means that I not only needed to record the type of the object ahead of its data, but also the globally unique identifier of the object instance; Otherwise, when saving or loading data a single object may get turned into multiple separate instances (due to its reference by other objects). Since repeated saving was benchmarked as more frequent than loading events I optimized for save speed and the unique ID was implemented simply by storing the integer value of the object's pointer prior to its type ID and property data in the stream. I also marked each object as "saved" in the memory management bookeeeping header that precedes all object instances... more about this later. If you're not using a custom allocator and can't rely on bookeeping metadata for the object instances then you can incorporate the "saved" flag into the lower or upper bit of the type ID, or use a map as a key / value store to keep track of which object pointers have been saved.
When storing an object I first determine whether or not it's already been stored by querying its status bit (or you can query the "saved" map for the existence of its pointer (key)). If the object is already saved this "batch" then I don't need to write the Type ID nor any instance data, only store the pointer to the object data in the output stream (or an otherwise unique per object instance number). If the object pointer to be stored is NULL then only the NULL pointer value is written and the storage function does not recurse.
When loading the data the pointer values loaded do not reflect the actual memory location of an object, of course. However, they are unique and thus allow me to build a key / value store when loading (I use a hashmap, but a treemap or other map will do). To perform loading I first read the object's unique instance ID (its old pointer) from the data stream, then I test for the existence of the key in this batch's "loaded" map. If the key does not exist then the object is instantiated using the type ID and data from the stream; The new object instance pointer is added to the the map as the value for the key of its "unique ID" (the old pointer loaded from the stream). The new instance pointer is returned from the instantiation factory function (which might be returned directly, or be setting the property of another object being loaded). If the key does exist, then its value (an instance pointer created this loading batch) is returned instead of instantiating a new object from the data stream. The special case of NULL is addressed by adding "NULL:NULL" (0:0) to the key:value map prior to loading a the batch of objects. Thus if an object has a pointer property that may reference another object type, but is set to NULL, then a new object of that referenced type is not instantiated, the pointer gets set to null as intended.
A note on bit-flag optimization: I was able to avoid plaicing a "saved" flag in the TypeID or using a map structure during saving since I had a custom allocator and garbage collector which provided some metadata prior to the object's pointer. In C and C++ when you call malloc() or new() the pointer value returned is typically advanced just beyond some record keeping data that is needed for the allocator to locate the memory management structures in order to free() the data. The free() or delete() function typically subtracts some constant value from the pointer passed in and this gets you to the memory management record keeping header data which then allows the standard lib (or kernel) to return the memory to the process's (or system's) memory pool. Since all of my allocation sizes are limited to aligning on word boundaries of the platform (16 or 32 or 64 bit = 2, 4 or 8 bytes), and I recorded the size of the allocation for range checking and to determine which size-range specific allocator to use: I was able to use the lowest bit of the "size" field of the allocation's GC header data as the "saved" bit, the low bit was simply masked off during free(). So, if you request 7 bytes you'll get allocated 8 usable bytes (round up to the nearest word boundary) + some header data (a "size" word, in this case), and the pointer returned will point to the usable data just beyond the memory management header. That's simply the overhead of dynamic memory allocation. Knowing this, however, allows you to place global record keeping information (such as a "stored" or "loaded" flag, or even the runtime type IDs) outside of the "user's" object definitions. This is how C++ provides your run time type data for introspection (on supporting compilers), beware that it may not be standardized across compilers, thus making it somewhat useless and necessitating a "roll your own anyway" approach (which I find, unfortunately, quite a common occurrence in C++).
You can provide your own compiler agnostic global instance data by overloading malloc() or new() and requesting a few bytes more from the underlying functions in order to store your record keeping data. Before you return the pointer, just be sure to advance the value returned from the underlying functions beyond your record keeping header. You'll also have to overload free() or delete() and manually modify (and reinterpret cast) the pointer value so that it actually points to the top of your object+header. Users of the returned value thus remain unawares that there's extra stuff before the instance pointer. Using this, and potentially a macro or two in your object definition, you can keep the clutter to a minimum rather than requiring each Object class to have unique code that explicitly performs serialization of its data. Note that if you decide to extend all objects from a root GameObject class, that including the typeID field in the root object may quickly slam your head into the object inheritance diamond problem. This is due to an absurd deficiency in C++ whereby the "virtual" keyword is applicable to methods, but not instance variables... If only variables could be declared "virtual" (and thus their position data added to the same VTABLE that "virtual" functions are), then C++ would be far less retarding to the implementation of advanced functionality as "pure virtual" classes could then contain vars (and templates wouldn't have to take up so much of the
slack).
Of course this is only one way to achieve the goal of generic global object save / load functionality. Each will have pros and cons. I primarily posted this to bring up the issue of deduplication & instance resolution. Look into the custom Allocator facilities that C++ provides, and esp: Per-class overriding of the new operator.
A word on the type property definition: For the purpose of object serialization one only needs to record the number of object pointers within the type data record, and the offset of each pointer within the type record. This can be trivially constructed by using a macro to create your type definition and a macro to declare pointers to objects within said type definition macro (or crazily constructed using a set of C++ template functions which abuse the preprocessor to perform addition and address-of operations to construct a "class_def" symbiote to make up for the lack of proper introspection, and once again "roll your own anyway").