Type System Design: Structural Typing, Variance, and Branding
Understand how TypeScript decides whether one type is assignable to another — structural compatibility, variance in function and generic positions, and techniques for opting into nominal-style behavior when structural typing isn't strict enough.
TypeScript's type system is fundamentally different from languages like Java or C# in one crucial way: it's structural, not nominal. Two types are compatible not because they were declared with the same name or in the same inheritance chain, but because they have compatible shapes. Understanding this — and the variance rules that govern function and generic assignability — explains a huge amount of TypeScript's behavior that otherwise looks inconsistent or surprising.
Structural typing vs nominal typing
In a nominally-typed language, a value of type Dog is assignable to a parameter expecting Animal only if Dog was explicitly declared to extend or implement Animal somewhere in the source. The compiler checks names and declared relationships, not shapes. In a structurally-typed language like TypeScript, a value is assignable to a target type if its shape is compatible with that target's shape — regardless of whether the two types have any declared relationship at all, or even whether they were designed with each other in mind.
This means two completely unrelated interfaces, Point2D { x: number; y: number } and Coordinate { x: number; y: number }, are mutually assignable in TypeScript — a Point2D value can be passed anywhere a Coordinate is expected and vice versa, because the compiler only cares that the required properties exist with compatible types. This is sometimes called 'duck typing, but checked at compile time': if it has the properties a type requires, it counts as that type.
Structural typing also means assignability is about having at least the required members — an object type is compatible with a target if it has all the target's required properties (excess properties are fine for values already stored in a variable; only object literals get flagged for excess properties directly at the call site, as a separate 'excess property check' convenience, not a fundamental structural rule).
Assignability and compatibility rules
For two object types, A is assignable to B if every required property of B exists on A with a compatible type. A may have extra properties B doesn't mention — that's fine, since structurally A still satisfies everything B requires. This is why a wider, more specific type can always be passed where a narrower, more general one is expected.
For primitives and literal types, assignability follows the expected subtype relationship: a literal type like "loading" is assignable to string, 42 is assignable to number, but not the reverse — you can't pass a general string where the literal "loading" is specifically required, because not every string is "loading".
Function types add their own compatibility rules on top of this (covered next), and this is where variance — the direction compatibility 'points' when types are nested inside generics or function signatures — becomes essential to understand precisely.
Variance: covariance, contravariance, and invariance
Variance describes how the assignability of a compound type (like Array<T> or a function type) relates to the assignability of the type(s) it's built from. A position is covariant if compatibility flows in the same direction as the underlying type (if Dog is assignable to Animal, then Container<Dog> is assignable to Container<Animal>). A position is contravariant if compatibility flows in the opposite direction (a function accepting Animal is assignable where a function accepting Dog is expected, not the other way around). A position is invariant if neither direction holds — the types must match exactly.
Return types are covariant: a function returning Dog can stand in for a function expected to return Animal, because any caller expecting an Animal back is happy with the more specific Dog. Parameter types are contravariant in a strictly sound type system: a function that can handle any Animal can safely be used where only a Dog-handling function is required, because it can handle a Dog too (and more). TypeScript, however, makes ordinary method parameters bivariant (checked loosely in both directions) for practical reasons of compatibility with common JavaScript patterns, while function-typed properties and variables declared with strictFunctionTypes enabled get the fully sound contravariant check.
Array and most built-in generic types are treated as covariant in their type parameter in TypeScript, even though this is technically unsound for mutable arrays (you could push an Animal into an Array<Dog> if the compiler let a Dog[] masquerade as Animal[] and then someone pushes a Cat). TypeScript accepts this known unsoundness as a pragmatic tradeoff, matching how most real-world JavaScript code is actually written and used.
Nominal typing with branding
Sometimes structural typing is too permissive: a UserId and an OrderId might both just be a string structurally, so nothing stops you from passing an OrderId where a UserId is expected — a real category of bug that a nominal type system would catch automatically. TypeScript has no built-in nominal typing, but the community pattern for approximating it is called 'branding' (or 'opaque types'): intersecting the underlying type with a unique marker property that never actually exists at runtime.
A branded type looks like type UserId = string & { readonly __brand: "UserId" }. Because no plain string structurally has a __brand property, you can't accidentally assign a raw string to a UserId — you have to go through an explicit constructor function that performs a type assertion, which becomes the single controlled place where the 'promotion' from string to UserId happens. Everywhere else, the compiler treats UserId and OrderId as incompatible, even though both compile away to ordinary strings at runtime.
This pattern costs nothing at runtime (the __brand property is erased entirely by the compiler — it's purely a compile-time fiction) but gains the safety of nominal typing exactly where you need it most: identifiers, currency amounts with different units, validated-vs-unvalidated data, and similar cases where 'same underlying primitive, different meaning' is a real source of bugs.
Branded type pattern
__brand never exists at runtime — it's purely a compile-time marker that makes two otherwise-identical structural types mutually incompatible. toUserId is the single controlled 'entry point' where a raw string becomes a trusted UserId; every other function can assume it received a properly branded value.
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function toUserId(raw: string): UserId {
return raw as UserId;
}
function getUser(id: UserId) {
/* ... */
}
const uid = toUserId("u_123");
getUser(uid); // OK
getUser("u_123"); // Error: string is not assignable to UserId
getUser("o_999" as OrderId); // Error: OrderId is not assignable to UserIdCode Examples
Point2D and Coordinate were never declared as related, but c has every property magnitude requires with compatible types, so structural typing accepts it without complaint. Expected result: 5
handleAnimal accepts anything with a kind field, so it can safely be used wherever a function accepting the more specific { kind; breed } shape is expected — this is contravariance in the parameter position, and TypeScript allows it because the wider-accepting function is always safe to use in place of a narrower-accepting one. Expected result: dog
Common mistake: Assuming variance rules for parameters mirror variance for return types leads to confusion — return types are covariant (narrower-returning is fine), but soundly-checked parameter types are contravariant (wider-accepting is fine) — these point in opposite directions.
interface Point2D {
x: number;
y: number;
}
interface Coordinate {
x: number;
y: number;
}
function magnitude(p: Point2D): number {
return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const c: Coordinate = { x: 3, y: 4 };
console.log(magnitude(c)); // works, no declared relationship neededtype AnimalHandler = (a: { kind: string }) => void;
type DogHandler = (d: { kind: string; breed: string }) => void;
const handleAnimal: AnimalHandler = (a) => console.log(a.kind);
// A handler that only needs the common fields can stand in
// for a handler that's expected to accept a narrower type,
// because it never touches the extra fields it doesn't need.
const handleDog: DogHandler = handleAnimal;
handleDog({ kind: "dog", breed: "Labrador" });Variance directions
Return types and parameter types are checked in opposite directions on purpose — that asymmetry is what makes the checks sound.
Return position (covariant):
() => Dog is assignable to () => Animal
(narrower OK where wider expected)
Parameter position (contravariant, strict mode):
(a: Animal) => void is assignable to (d: Dog) => void
(wider-accepting OK where narrower expected)
Array<T> (covariant, pragmatically unsound):
Dog[] is assignable to Animal[]Important Rules
TypeScript compares object types by shape (structural typing), not by declared name or inheritance chain — any two types with compatible members are mutually assignable.
Return types are covariant (a more specific return type is fine); soundly-checked function parameter types are contravariant (a function accepting a broader type is fine where a narrower one is expected).
Ordinary method parameters are checked bivariantly by default for compatibility with common JS patterns; enable strictFunctionTypes to get sound contravariant checking for standalone function-typed values.
Built-in generics like Array<T> are treated as covariant in TypeScript even though this is technically unsound for mutable containers — a pragmatic tradeoff, not a guarantee of full type safety.
Use branded/opaque types (T & { __brand: "X" }) when structurally-identical types (like two different string-based IDs) need to be treated as incompatible by the compiler.
Where nominal-style safety earns its keep
Large codebases commonly brand identifier types (UserId, ProductId, SessionToken), units of currency or measurement (Cents vs Dollars, Meters vs Feet), and validated-vs-raw data (SanitizedHtml vs string) precisely because these are all cases where two values share an underlying primitive representation but mixing them up is a real, costly bug — exactly the gap structural typing alone doesn't close.
type UserId = string; type OrderId = string; function getUser(id: UserId) { /*... */ } const oid: OrderId = "o_1"; getUser(oid); // compiles, but is semantically wrong type UserId = string & { readonly __brand: "UserId" }; type OrderId = string & { readonly __brand: "OrderId" }; function getUser(id: UserId) { /*... */ } // getUser(oid) now fails to compile if oid is an OrderId
Plain type aliases over string are structurally identical and interchangeable, silently allowing an OrderId to be passed where a UserId is required. Branding makes the two incompatible even though both are strings underneath.
[multiple-choice] Two interfaces, A { x: number } and B { x: number }, are declared with no relationship to each other. Is a value of type A assignable where B is expected? A. No, unless A explicitly extends B B. Yes, because TypeScript uses structural typing and A's shape satisfies B's requirements C. Only if they're in the same file D. Only with an explicit type assertion
B. Yes, because TypeScript uses structural typing and A's shape satisfies B's requirements — TypeScript is structurally typed: assignability depends on shape compatibility, not declared relationships, so A satisfies B automatically.
[multiple-choice] Which best describes the variance of function return types versus (soundly-checked) parameter types? A. Both are covariant B. Both are contravariant C. Return types are covariant, parameter types are contravariant D. Return types are contravariant, parameter types are covariant
C. Return types are covariant, parameter types are contravariant — A narrower return type is safe where a wider one is expected (covariant), while a parameter type that accepts more is safe where a narrower parameter is expected (contravariant) — opposite directions.
[true-false] Branded types like string & { __brand: "UserId" } change how the value behaves at runtime. A. True B. False
B. False — Branding is purely a compile-time construct — the __brand property is erased entirely; at runtime the value is an ordinary string with no extra property or behavior.
Define branded types Meters and Feet, each based on number. Write constructor functions toMeters(n: number): Meters and toFeet(n: number): Feet, and a function addMeters(a: Meters, b: Meters): Meters that only accepts Meters values, not raw numbers or Feet.
type Meters = number & { readonly __brand: "Meters" }; type Feet = number & { readonly __brand: "Feet" }; function toMeters(n: number): Meters { return n as Meters; } function toFeet(n: number): Feet { return n as Feet; } function addMeters(a: Meters, b: Meters): Meters { return (a + b) as Meters; }
Use number & { readonly __brand: "Meters" } and a type assertion inside each constructor function.