A static type can prove that a program is internally consistent without leaving behind an object that describes that proof. This distinction is easy to miss because source code uses one word, “type,” for two different things: a compile-time proposition checked by a tool and runtime information inspected by executing code. Type erasure removes some or all of the former representation, while reification deliberately makes selected type facts available as values, tags, metadata, layouts, or generated validators.
Neither strategy is universally safer or faster. Erasure can produce small, interoperable output and lets abstractions disappear after checking. Reification enables reflection, serialization, dependency lookup, and dynamic dispatch, but it costs space, constrains evolution, and can create false confidence when metadata is incomplete. The practical rule is to preserve only the runtime evidence an operation actually needs, and never mistake a static annotation for validation of data that entered from outside the typed program.
Separate Static Proofs from Runtime Evidence
Consider a TypeScript function that accepts a User:
interface User {
id: string;
active: boolean;
}
function greeting(user: User): string {
return user.active ? `Welcome ${user.id}` : `Account ${user.id} is inactive`;
}
The compiler checks property access at development time. In emitted JavaScript, the interface is absent. The function receives an ordinary value and reads two properties. If a caller written in JavaScript passes { id: 42, active: "yes" }, no runtime User guard appears merely because the parameter was annotated.
That does not make the annotation useless. Within the checked compilation unit, it prevents many invalid constructions and propagates knowledge through control flow. Its guarantee is conditional: values must enter through paths that honor the type system. Network responses, parsed JSON, environment variables, database rows, browser storage, message queues, plugin calls, and unchecked assertions break that condition unless code validates them.
Runtime evidence answers narrower, operational questions. A collector may need an object’s memory layout. A serializer may need field names. A dependency container may need a token that identifies a service. A pattern match may need a discriminant. A validator needs predicates for required properties and ranges. These are not interchangeable. Knowing that a value was constructed by Invoice does not prove that its nested fields satisfy today’s invoice schema, and knowing a field layout does not recover a source-level alias or refinement.
It is therefore more precise to ask, “Which fact must this runtime operation observe?” than, “Does this language keep types at runtime?” Most languages preserve some facts and erase others at different compilation stages.
Trace What Actually Survives Compilation
Compilation can preserve type-related information in several forms. Machine code must usually retain enough layout information to load fields and call functions, but that information may be encoded in instructions rather than exposed through reflection. A garbage collector may know which words are references without knowing a source type’s name. A virtual method table can support dynamic dispatch without describing generic arguments.
Common implementation strategies illustrate the range:
| Strategy | Typical surviving evidence | Information commonly absent |
|---|---|---|
| Structural erasure | Runtime values, JavaScript tags, prototypes | Interfaces, aliases, unions, generic parameters |
| Erased generics | Raw class identity and casts inserted by the compiler | Most type arguments such as List<String> |
| Reified generics | Runtime generic arguments for supported constructs | Refinements, local inference decisions, arbitrary predicates |
| Monomorphization | Specialized code and concrete layouts per instantiation | A uniform reflective description of the source abstraction |
| Explicit schema | Authored or generated field rules and tags | Any rule omitted from that schema |
These are tendencies, not a ranking. Java traditionally erases most generic arguments but preserves class metadata and array component types. C# exposes more generic argument information through its runtime. Rust commonly specializes generic code for concrete types, yet source-level traits and aliases do not automatically become a general reflection API. TypeScript erases its declarations because JavaScript is the execution target, while JavaScript still provides its own limited observations such as typeof, prototypes, property keys, and branded built-ins.
Even a reified representation has a boundary. A runtime may report Array<String>, but it usually cannot prove that every element was produced by an approved sanitizer. A field declared as an integer may still need a business range. A nominal class name says little after data has crossed a serialization format that preserves neither prototype nor constructor.
To reason accurately, inspect the emitted artifact or runtime API. Ask whether names, arguments, annotations, discriminants, layout, and constraints survive independently. “Reflection is available” is not enough detail for a design decision.
Understand the Limits of Erased Generics
An erased type parameter cannot be inspected as though it were a runtime variable. This TypeScript function is impossible as written:
function isValue<T>(input: unknown): input is T {
return input instanceof T;
// Error: T is a type, but is being used as a value.
}
T tells the checker how uses of the function relate; no constructor named T is passed at runtime. The same limitation appears when trying to create new T(), branch on a type alias, or validate Array<T> without a validator for its elements.
Built-in runtime checks only answer their own questions. typeof input === "object" includes arrays and null. input instanceof UserClass checks a prototype relationship in one JavaScript realm; it does not validate structural fields, and it can fail for objects from another realm or after JSON parsing. constructor.name can change under bundling and can be forged. Testing whether a property exists narrows one structural fact, not the complete object.
Erasure also explains why unchecked casts are one-way statements. Writing payload as User changes the checker’s belief but emits no conversion and performs no inspection. Double assertions such as payload as unknown as User can silence almost any disagreement. They are appropriate only when some other mechanism establishes the invariant and the code can name that mechanism precisely.
Erased abstraction has a benefit: generic code cannot secretly select behavior based on arbitrary type arguments. A function that receives only values must operate through capabilities those values expose. This supports reuse and can make representation changes cheaper. Problems begin when an API promises runtime behavior, such as decoding or constructing T, without accepting runtime evidence sufficient to perform it.
Reify Only the Evidence an Operation Needs
Reification means turning a type-relevant fact into something executable code can carry. Several patterns cover different needs.
A constructor token supports allocation and nominal checks:
type Constructor<T> = new (...args: never[]) => T;
function make<T>(implementation: Constructor<T>): T {
return new implementation();
}
A discriminant supports exhaustive branching:
type Command =
| { kind: 'send-email'; to: string }
| { kind: 'close-account'; accountId: string };
A validator supports structural boundary checks:
type Decoder<T> = (input: unknown) => T;
function arrayOf<T>(decodeItem: Decoder<T>): Decoder<T[]> {
return (input) => {
if (!Array.isArray(input)) throw new Error('Expected an array');
return input.map(decodeItem);
};
}
An opaque token such as a unique symbol can identify a dependency without exposing a class. A schema can drive validation, documentation, and serialization. A numeric wire tag can remain stable when source names change. Generated metadata can connect a static declaration to a runtime artifact, provided generation is part of the build and drift is tested.
Choose by required capability. Passing a constructor when code only needs validation couples plain data to class identity. Passing a large reflective schema when code only needs a stable registry key exposes unnecessary detail. Conversely, a string token is not enough to deserialize an object. Reified evidence should be explicit in the API, so a function that claims to decode T accepts a Decoder<T> rather than pretending the generic parameter performs work.
Work Through an Untrusted Event Boundary
Suppose a worker consumes JSON events from a queue. Two event kinds share an envelope, and producers may deploy independently. Parsing JSON establishes syntax only; it returns an untrusted value whose shape, version, and ranges remain unknown.
type AccountEvent =
| {
type: 'account-opened';
version: 1;
eventId: string;
payload: { accountId: string; ownerEmail: string };
}
| {
type: 'credit-applied';
version: 1;
eventId: string;
payload: { accountId: string; cents: number };
};
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === 'object' && input !== null && !Array.isArray(input);
}
function requiredString(
record: Record<string, unknown>,
key: string,
): string {
const value = record[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Expected non-empty string at ${key}`);
}
return value;
}
function decodeAccountEvent(input: unknown): AccountEvent {
if (!isRecord(input)) throw new Error('Event must be an object');
const type = requiredString(input, 'type');
const eventId = requiredString(input, 'eventId');
if (input.version !== 1) throw new Error('Unsupported event version');
if (!isRecord(input.payload)) throw new Error('Payload must be an object');
const accountId = requiredString(input.payload, 'accountId');
if (type === 'account-opened') {
const ownerEmail = requiredString(input.payload, 'ownerEmail');
return { type, version: 1, eventId, payload: { accountId, ownerEmail } };
}
if (type === 'credit-applied') {
const cents = input.payload.cents;
if (!Number.isSafeInteger(cents) || cents <= 0) {
throw new Error('Credit cents must be a positive safe integer');
}
return { type, version: 1, eventId, payload: { accountId, cents } };
}
throw new Error(`Unsupported event type: ${type}`);
}
function parseAccountEvent(json: string): AccountEvent {
return decodeAccountEvent(JSON.parse(json) as unknown);
}
The decoder is a runtime witness for AccountEvent. It checks the discriminant before interpreting the payload, rejects unsupported versions, and constructs a fresh value containing only recognized fields. After it returns, downstream TypeScript code can rely on the union because the boundary converted unknown into a validated domain value.
For a larger schema, use a mature runtime-schema library or generate validators from one authoritative definition. The hand-written example exposes the mechanics, but manual field code becomes repetitive and is easy to drift. Validation must still include domain constraints that a broad schema format cannot infer, such as whether an account exists or an event ID has already been processed.
Treat Serialization as a Lossy Representation Change
Serialization does not pause an object and resume it elsewhere. It maps a value into a wire representation with its own type system, then constructs a new value from that representation. JSON has objects, arrays, strings, numbers, booleans, and null. It does not natively preserve Date, Map, Set, BigInt, class prototypes, symbol keys, object identity, cycles, or a distinction between many numeric types.
This is why JSON.parse cannot reify a source annotation. A timestamp returns as a string until a decoder validates its format and deliberately constructs a Date or domain-specific instant. A class instance returns as a plain object. Large integers may lose precision before validation if the parser represents every number as a floating-point value.
Reflection metadata has similar limits. Decorators may record that a property was declared as Array, but not the element constraint. Generic wrappers, unions, optionality, branded values, and conditional types may collapse into broad constructor metadata. Tooling that infers a complete API schema from partial reflection can silently accept more than the static declaration intended.
Design wire schemas as versioned contracts rather than leaked memory models. Give variants stable tags, define unknown-field policy, specify numeric and text constraints, and decide how old readers handle new variants. Keep conversion between wire and domain values explicit. This makes schema evolution reviewable and prevents a convenient serializer from becoming the accidental owner of compatibility.
Balance Duplication, Coupling, and Failure Modes
Every runtime representation introduces a consistency problem: how does it stay aligned with the static type? Writing an interface and a separate validator duplicates structure. Generating one from the other reduces repetition but makes the generator and its supported type subset part of the trusted toolchain. Defining the runtime schema first and deriving a static type avoids drift, though the schema language may express less than the host type system.
Several failure modes recur:
- Metadata optimism: code checks that metadata exists and assumes it proves nested values. Test what the metadata actually contains.
- Discriminant trust: code branches on
kindbut never validates the selected payload. A tag chooses a decoder; it does not replace one. - Generic laundering: a registry returns
Tbased on a caller-supplied string and uses a cast internally. Bind tokens to decoders or constructors in one typed registry. - Cross-realm nominal checks:
instanceofrejects a structurally valid browser object from another realm. Prefer protocol-level checks when identity is not the contract. - Overvalidation: repeatedly decoding already trusted internal values wastes work and obscures ownership. Validate once at the trust boundary, then preserve the validated type.
- Schema lock-in: exposing rich language-specific reflection on the wire makes consumers depend on implementation details. Publish a smaller, stable contract.
Reification also has operational cost. Reflective metadata increases artifacts and can inhibit tree shaking. Monomorphized code can grow with each instantiation. Dynamic reflection can delay errors until execution and make reachability harder for ahead-of-time tooling. Explicit validators add CPU work proportional to input size, so boundaries need size and depth limits before recursive validation begins.
The right tradeoff is rarely “keep all types.” Preserve stable tags and validation rules where data crosses trust or compatibility boundaries. Let purely local proof machinery erase when runtime code has no use for it.
Verify the Contract at Build Time and Runtime
Test static and runtime guarantees separately. Compile-time tests should assert that valid values type-check and forbidden combinations do not. Runtime tests should call decoders with unknown, including values that no well-typed caller would construct.
For the event decoder, cover each valid variant, missing envelope fields, null, arrays in place of objects, unknown versions, unknown types, empty strings, fractional or unsafe integers, and payload fields belonging to the wrong variant. Property-based generators can produce nested JSON values and check that decoding either returns a value satisfying an independent invariant or throws a classified validation error. Fuzz parsers with bounded input sizes to find deep nesting and resource exhaustion cases.
Add a drift check when static and runtime declarations are separate. This can compile representative decoded values against the domain type, compare generated schemas in CI, or make the schema the source from which the type is inferred. Compatibility tests should replay fixtures from every supported wire version and confirm that newly encoded messages remain readable by the oldest supported consumer where that is promised.
In operation, count validation failures by schema version, event type, producer identity, and stable error code. Do not log entire rejected payloads by default; boundary data may contain credentials or personal information. Alert on a change in failure rate after producer or consumer deployment, retain a bounded quarantine path for diagnosis, and distinguish malformed data from an intentionally unsupported future version.
The core discipline is straightforward: static types govern code under the compiler’s assumptions; runtime evidence governs observations made by the executing program. Erasure is safe when no runtime operation needs the missing fact. Reification is useful when it carries exactly the constructor, token, tag, layout, or predicate an operation requires. At untrusted boundaries, accept unknown, validate it with explicit evidence, and only then grant it a domain type. That sequence makes the trust transition visible, testable, and honest.