The Type System, Deep — Structural Typing & How Inference Works
You've spent eight phases using TypeScript's types. This phase is where you learn how the type checker actually thinks — the rules it follows when it decides whether one type fits another, and where the types you never wrote come from. This is the advanced half of the guide, the "how it really works under the hood" turn, and it pays off everywhere: once you understand these mechanics, the checker's most baffling errors (and its most surprising silences) become predictable.
Here's the mental model to carry through the whole phase: TypeScript judges types by their shape, not their name, and it does an enormous amount of guessing on your behalf. Most of the time you never write a type annotation — TypeScript infers one. Knowing which type it infers, and why, is the difference between fighting the checker and steering it.
Structural typing — "duck typing for types"
The first thing to internalize is how TypeScript decides whether two types are the same, or compatible. Coming from Java or C#, you'd expect this to hinge on names: a value is a Point only if it was declared as a Point. TypeScript does the opposite.
📝 Structural typing — types are compared by their structure (the members they have), not by their declared name. If a value has at least all the members a target type requires, it's compatible with that target — even if the two were never related on paper. The contrast is nominal typing (Java, C#), where compatibility depends on the explicit name in the declaration.
You may know this idea from runtime languages as "duck typing": if it walks like a duck and quacks like a duck, treat it as a duck. TypeScript applies that same principle to static types.
interface Point {
x: number;
y: number;
}
function printPoint(p: Point): void {
console.log(`(${p.x}, ${p.y})`);
}
// Never declared as a Point — just an object with x and y.
const location = { x: 10, y: 20 };
printPoint(location); // ✅ accepted
// A class that has no idea Point exists.
class Vector {
constructor(public x: number, public y: number) {}
}
printPoint(new Vector(3, 4)); // ✅ also accepted
What just happened: Neither location nor Vector mentions Point anywhere. In Java this would be a compile error — they're not declared to implement the interface. But TypeScript only checks the shape: does this value have an x: number and a y: number? Both do, so both are valid Points. The name on the type is just a label for humans; the checker reasons entirely about members.
💡 Why this design. JavaScript objects are bags of properties created on the fly — from JSON, from literals, from libraries that never heard of your types. Nominal typing would reject all of them. Structural typing lets TypeScript describe code that already exists without forcing you to retrofit implements clauses onto every object. It's the rule that makes TypeScript practical to bolt onto real JavaScript.
Assignability & the excess-property surprise
Structural typing is governed by one core question: is type X assignable to type Y? Get this phrasing right and most type errors decode themselves.
📝 Assignability — X is assignable to Y if X has at least everything Y requires. Y is the contract ("I need an x and a y"); X satisfies it as long as it provides those, regardless of extra members it might carry. More properties is fine; missing a required one is not.
That "more is fine" direction surprises people, so look at it directly:
interface Named {
name: string;
}
const employee = { name: "Ada", salary: 90000 };
const n: Named = employee; // ✅ employee has name (plus extra) — assignable
What just happened: Named requires only a name: string. employee has that, plus a salary field. The extra property doesn't disqualify it — employee provides everything the Named contract asks for, so it's assignable. Through the variable n, TypeScript only lets you see .name, but the value underneath still carries salary at runtime.
Now the gotcha that bites everyone:
⚠️ Excess-property checks fire on object literals only. When you assign a fresh object literal directly to a typed target, TypeScript runs an extra check: it rejects properties the target doesn't declare. This contradicts the "more is fine" rule above — on purpose — to catch typos. But route the same object through an intermediate variable and the check vanishes.
interface Options {
width: number;
height: number;
}
// Direct literal — excess-property check fires.
const a: Options = { width: 100, height: 50, depth: 10 };
Object literal may only specify known properties, and 'depth' does not
exist in type 'Options'.
The fix — and the reason the rule exists — is to assign through a variable, which downgrades the check to ordinary assignability:
interface Options {
width: number;
height: number;
}
const raw = { width: 100, height: 50, depth: 10 };
const b: Options = raw; // ✅ no excess-property check — plain assignability
What just happened: The first version hands a brand-new literal straight to an Options variable. TypeScript assumes a literal written right there should match the target exactly, so the stray depth is almost certainly a typo (maybe you meant width?) and it errors. The second version assigns raw first; by the time raw reaches b, it's a value, not a literal, so the normal rule applies — raw has everything Options needs, extra depth and all, so it's assignable. Same object, different rule, because one is a literal at the point of assignment and the other isn't.
💡 The excess-property check is a deliberate, narrow exception to structural typing — a usability feature aimed at the single most common mistake (a misspelled or misremembered property name in a literal). When you genuinely want the extra property, the intermediate-variable form tells the checker "I meant to do this."
Type widening — why let and const infer differently
When you don't annotate, TypeScript infers a type. But it doesn't always infer the narrowest possible one — it sometimes widens a literal to its general type. Whether it does depends on whether the binding can change.
📝 Type widening — when TypeScript infers a type from a literal value, it broadens (widens) the specific literal to its general type for mutable bindings. let x = "hi" infers string, not the literal type "hi", because you might reassign x later. A const can never be reassigned, so const x = "hi" keeps the exact literal type "hi".
let mutable = "hi"; // inferred type: string
const immutable = "hi"; // inferred type: "hi" (a literal type)
mutable = "bye"; // ✅ fine — string accepts any string
// immutable = "bye"; // would error — "hi" accepts only "hi"
let count = 42; // inferred: number
const max = 42; // inferred: 42
What just happened: mutable is a let, so it could be reassigned to any other string — inferring the locked-down literal "hi" would make mutable = "bye" an error, which would be absurd. TypeScript widens it to string. immutable is a const; it physically cannot change, so TypeScript keeps the most precise type it knows, the literal "hi". The same split applies to count (number) versus max (42).
💡 Why this matters. Literal types are what power discriminated unions, exhaustive switch checks, and precise function arguments. When you want that precision, const (or the annotations below) preserves it; when you want flexibility, let gives you the general type. The kind of binding you choose silently shapes the type you get.
as const — freezing a value to its narrowest types
const only stops reassignment of the variable. It does nothing for the contents of an object or array — those still get widened, member by member. When you want everything inside frozen to its exact literal types, you reach for as const.
📝 as const — a const assertion applied to a value. It tells TypeScript to infer the narrowest possible type: every member becomes its exact literal type, and the whole structure becomes deeply readonly. It's the tool for fixed configuration objects and discriminated-union tags.
Watch the difference:
// Without as const — members are widened.
const config1 = { mode: "dark", retries: 3 };
// inferred: { mode: string; retries: number }
// config1.mode is just string — "light", "anything" would type-check
// With as const — members are pinned and readonly.
const config2 = { mode: "dark", retries: 3 } as const;
// inferred: { readonly mode: "dark"; readonly retries: 3 }
// config2.mode is exactly "dark", and you can't reassign it
What just happened: In config1, the outer const stops you from reassigning the whole config1 variable, but each property is inferred with widening — mode becomes string, retries becomes number. In config2, as const flips every member to its literal type ("dark", 3) and marks them all readonly. The object is now a precise, immutable description of itself.
This is the canonical fix for losing literal types where you need them — most often a union tag:
type Action =
| { type: "increment"; by: number }
| { type: "reset" };
// Without as const, `type` widens to string and won't match the union.
const bad = { type: "increment", by: 1 };
// const result1: Action = bad; // ❌ string not assignable to "increment"
const good = { type: "increment", by: 1 } as const;
const result2: Action = good; // ✅ type is exactly "increment"
What just happened: Action is a discriminated union keyed on the literal type field. Plain inference widens bad.type to string, which doesn't fit either branch of the union, so the assignment fails. as const keeps good.type as the literal "increment", which matches the first branch exactly, and the assignment succeeds. Any time an object needs to be a specific union member, as const is how you keep its tag literal.
How inference flows — let TypeScript do the work
You've seen TypeScript infer from values. It also infers from context — the surrounding code tells it what a type should be, so you don't have to annotate.
📝 Contextual typing — TypeScript infers a value's type from the position it appears in. The classic case is a callback: the parameter types of an inline function are inferred from the function that receives it.
const nums = [1, 2, 3];
// No annotation on n — TypeScript knows nums is number[],
// so .map's callback parameter must be a number.
const doubled = nums.map((n) => n * 2); // n: number, doubled: number[]
const words = ["a", "bb", "ccc"];
const lengths = words.map((w) => w.length); // w: string, lengths: number[]
What just happened: You never wrote a type for n or w. Because nums is number[], the type of .map's callback parameter is fixed to number by context, so n is a number; words is string[], so w is a string. The return type is inferred too — n * 2 is a number, so doubled is number[]. Annotating any of these would be noise; the context already pins them down.
Return-type inference works the same way for your own functions — TypeScript reads the return statements:
function makeUser(name: string, age: number) {
return { name, age, active: true };
// inferred return type: { name: string; age: number; active: boolean }
}
What just happened: You annotated the parameters (the boundary, where data enters) but not the return. TypeScript computed the return type from the object you return. Adding : { name: string; age: number; active: boolean } would just restate what it already knows — and risk drifting out of sync if you later add a field.
💡 The practical rule. Let inference do the work inside your code; annotate at the boundaries and when you want to pin a type. Annotate function parameters and exported/public signatures (so callers get a stable contract and errors point at the right place). Skip annotations on local variables, callback parameters, and return types where inference is clearly correct. Over-annotating is a common beginner habit that adds noise and creates places for types to disagree with reality.
One modern tool deserves a mention here. Sometimes you want to check a value against a type without widening it to that type — keeping the precise inferred type for later use. That's satisfies:
type Theme = Record<string, string>;
// `: Theme` would widen palette to Record<string, string>,
// losing the specific keys. `satisfies` checks AND keeps them.
const palette = {
primary: "#2563eb",
danger: "#dc2626",
} satisfies Theme;
palette.primary; // ✅ still known to exist
// palette.missing; // ❌ caught — not a key of palette
What just happened: Annotating const palette: Theme would verify the shape but then treat palette as a plain Record<string, string>, so the checker would forget the specific keys (primary, danger). satisfies Theme runs the same compatibility check — every value must be a string — but leaves palette's narrow inferred type intact, so you keep autocomplete and key-existence checks. It's "validate against a type, but don't widen to it."
Recap
- Structural typing — TypeScript compares types by their shape (their members), not their declared name. An unrelated object or class is accepted anywhere its members satisfy the target. This is "duck typing" for static types, and it's what makes TS fit real JavaScript.
- Assignability means X provides at least everything Y requires — extra properties are fine. The exception is the excess-property check, which fires only on object literals assigned directly to a typed target (to catch typos); routing through an intermediate variable downgrades it to plain assignability.
- Widening: inference from a literal broadens to the general type for mutable bindings (
let x = "hi"→string) but keeps the exact literal forconst(const x = "hi"→"hi"), because aconstcan never change. as constfreezes a value to its narrowest literal types and makes it deeplyreadonly— essential for fixed config and for keeping discriminated-union tags as literals instead of widening them tostring.- Inference flows from context: callback parameters are typed by where they're used (
arr.map(x => ...)knowsx), and return types are computed fromreturnstatements. The rule of thumb is to annotate boundaries and let inference handle the inside;satisfieschecks a value against a type without widening it.
With the checker's reasoning demystified, you're ready to transform types programmatically — utility and mapped types build new types out of existing ones, and they lean directly on the assignability and inference rules you just learned.
Quick check
Lock in the three ideas most likely to trip you up — shape-based compatibility, the excess-property exception, and what as const preserves:
[
{
"q": "A function expects a `Point` (interface with `x: number; y: number`). You pass `new Vector(3, 4)`, a class that never mentions `Point`. Why does TypeScript accept it?",
"choices": [
"TypeScript uses structural typing — Vector has the required `x` and `y` members, so its shape matches `Point` regardless of its name",
"TypeScript silently converts the Vector into a Point at runtime",
"Classes are exempt from type checking when passed to functions",
"It only works because Vector and Point happen to start with similar letters"
],
"answer": 0,
"explain": "TypeScript compares by shape, not name. `Vector` has `x: number` and `y: number`, which is everything `Point` requires, so it's assignable. The declared name is irrelevant — that's structural (duck) typing."
},
{
"q": "`const a: Options = { width: 100, height: 50, depth: 10 }` errors on `depth`, but assigning the same object through a variable first does not. Why?",
"choices": [
"Excess-property checks fire only on object literals assigned directly to a typed target; an intermediate variable falls back to ordinary assignability, where extra properties are allowed",
"The variable version deletes the `depth` property automatically",
"Object literals are immutable and variables are not, so the rules differ",
"It's a compiler bug — both forms should error"
],
"answer": 0,
"explain": "The excess-property check is a special case aimed at catching typos in literals. It applies only to a literal assigned straight to a typed slot. Through a variable, the normal 'at least everything required' rule applies, and extra properties are fine."
},
{
"q": "You write `const action = { type: \"increment\", by: 1 }` and try to assign it to a discriminated union keyed on `type`. It fails. What fixes it?",
"choices": [
"Add `as const` so `type` keeps its literal type \"increment\" instead of being widened to `string`",
"Change `const` to `let` so the type becomes mutable",
"Remove the `by` field so the object is smaller",
"Nothing — discriminated unions can't be built from object literals"
],
"answer": 0,
"explain": "Plain inference widens `type` to `string`, which matches no branch of the union. `as const` pins every member to its exact literal type, so `type` stays \"increment\" and the object matches that branch of the union."
}
]
← Phase 8: Modules, tsconfig & the Build · Guide overview · Phase 10: Utility & Mapped Types →
Check your understanding
1. A function expects a `Point` (interface with `x: number; y: number`). You pass `new Vector(3, 4)`, a class that never mentions `Point`. Why does TypeScript accept it?
2. `const a: Options = { width: 100, height: 50, depth: 10 }` errors on `depth`, but assigning the same object through a variable first does not. Why?
3. You write `const action = { type: "increment", by: 1 }` and try to assign it to a discriminated union keyed on `type`. It fails. What fixes it?