Assignability
“Is A assignable to B?” is a real algorithm, not a vibe
Section titled ““Is A assignable to B?” is a real algorithm, not a vibe”Every TS2322, every generic constraint check, every function-call argument check — all of it reduces to one question the checker asks over and over: is the source type assignable to the target type? This lesson is about what that check actually does, because “structural typing” as a slogan (“same shape, compatible”) hides several rules that only show up when you watch a real comparison run.
The core of the algorithm, for two object types, is: for every required property the target declares, the source must have a compatible property of the same name. Not “the same properties” — the target’s properties, checked one at a time, source-has-it-and-it’s-compatible:
interface Target { name: string; age: number }interface Source { name: string; age: number; email: string }
declare const s: Source;const t: Target = s; // OK -- Source has every property Target requires, plus moreSource has an extra email field Target never asked for — irrelevant. The comparison walks Target’s property list, not Source’s. This one rule is also why a wider type (more possible values, like a plain object) is not generally assignable to a narrower one (fewer possible values, more specific properties): the extra properties the narrower type demands might not be there.
Reading a real assignability error: the chain names the first failure, then stops
Section titled “Reading a real assignability error: the chain names the first failure, then stops”When a comparison involves nested objects, the checker doesn’t dump every mismatch at once — it walks into the first incompatible property, then the first incompatible property of that, and so on, printing exactly the path it took:
A 3-level nested mismatch, and how the checker reports it
// @expect-error TS2322interface Address { city: string; zip: number }interface Company { name: string; address: Address }interface Employee { id: string; company: Company }
declare const e: Employee;const bad: { id: string; company: { name: string; address: { city: string; zip: string } } } = e;// ^? error TS2322console.log(bad); Compiling that (verified on 7.0.2) prints:
error TS2322: Type 'Employee' is not assignable to type '{ id: string; company: { name: string; address: { city: string; zip: string; }; }; }'. The types of 'company.address.zip' are incompatible between these types. Type 'number' is not assignable to type 'string'.Three lines, three levels, one real incompatibility. The top line names the two whole types involved — that’s context, not the bug. Each nested line narrows the path by one property (company, then address, then zip) until it reaches a pair of types that are actually incompatible (number vs string), and stops there — it does not keep walking into every other property that happens to differ, and it does not report more than the one path that broke the comparison. Reading any assignability chain the same way — skip to the innermost line for the real cause, read the outer lines as “here’s the property path that led there” — turns a wall of nested types into one concrete fact.
readonly is not part of assignability — at all
Section titled “readonly is not part of assignability — at all”This surprises almost everyone the first time they see it: a readonly modifier affects whether you can write to a property, but it plays no role in whether one type is assignable to another, in either direction:
interface Mutable { x: number }interface ReadonlyPoint { readonly x: number }
declare const m: Mutable;declare const r: ReadonlyPoint;
const a: ReadonlyPoint = m; // mutable -> readonly: fineconst b: Mutable = r; // readonly -> mutable: ALSO fineconsole.log(a, b);Both directions compile with zero errors (verified). The checker only compares the value type of each property — x: number matches x: number regardless of which side spells it readonly. This is a real, sharp footgun: b.x = 99 now compiles, silently mutating through a reference that was supposed to be protected, because the type system’s readonly is a compile-time-only annotation on the binding, not a fact baked into the object’s structural identity the way a property’s name or value type is.
Methods are bivariant; property functions are not
Section titled “Methods are bivariant; property functions are not”A function-shaped member behaves differently depending on whether it’s declared as a method (handle(a: Animal): void) or as a property holding a function value (handle: (a: Animal) => void) — and the difference is not cosmetic:
// @expect-error TS2322interface Animal { name: string }interface Dog extends Animal { bark(): void }
interface WithProperty { handle: (a: Animal) => void } // property function -- checked contravariantly
const dogOnly = (a: Dog) => { a.bark(); };const p: WithProperty = { handle: dogOnly };dogOnly only knows how to handle a Dog (it calls .bark(), which only Dog has), but WithProperty.handle promises to accept any Animal. Assigning it is genuinely unsound — if something calls p.handle(someCat), dogOnly calls .bark() on a Cat and crashes — and strictFunctionTypes correctly rejects it with real TS2322.
Now the identical unsoundness, through a method signature instead of a property:
interface Animal { name: string }interface Dog extends Animal { bark(): void }
interface WithMethod { handle(a: Animal): void } // method shorthand -- checked bivariantly
const dogOnly = (a: Dog) => { a.bark(); };const m: WithMethod = { handle: dogOnly }; // compiles -- zero errorsThis compiles clean (verified) — the exact same unsound assignment strictFunctionTypes blocks for a property function is allowed for a method. This is method bivariance, and it’s not an oversight: it exists because it makes common generic container patterns (an array method like .push, a class method overridden in a subclass with a narrower parameter) usable without constant friction, at the cost of this one specific, documented hole. The practical rule for reading (or writing) code: a function type declared as a method (name(args): ret) gets the more permissive, occasionally-unsound check; the identical function type declared as a property (name: (args) => ret) gets the strict, sound one. Nothing else about the two forms differs.
any, unknown, and never each break the normal rule differently
Section titled “any, unknown, and never each break the normal rule differently”Three types get special-cased in the assignability relation, and each breaks it in its own specific direction:
// @expect-error TS2322declare const a: any;const s1: string = a; // any -> string: OK (any is assignable TO anything)declare const s2: string;const a2: any = s2; // string -> any: OK (anything is assignable TO any)
declare const u: unknown;const s3: string = u; // unknown -> string: error -- must narrow firstdeclare const s4: string;const u2: unknown = s4; // string -> unknown: OK -- unknown accepts everything
declare const n: never;const s5: string = n; // never -> string: OK (never is assignable to everything)const bad: never = "x"; // string -> never: error -- nothing but never is assignable TO neverany is assignable both ways — into it and out of it — which is precisely what makes it dangerous: it disables the check in both directions at once, silently. unknown accepts anything into it, but nothing comes back out without narrowing — it’s any’s safety-conscious sibling, all of the flexibility on input, none of the trust on output. never sits at the opposite pole from any: it’s assignable to everything (there are no values to violate any target’s requirements) but nothing is assignable to it (there’s no value that could ever be a never). This is also why “subtype of” and “assignable to” aren’t quite synonyms even though they coincide almost everywhere else: never is the bottom subtype of every type, and this bottom-of-everything behavior is what exhaustiveness checks (covered in the Narrowing lesson) actually lean on.
A void-returning function type has one more special case worth knowing: a function that returns something other than void is still assignable where a () => void is expected, because the caller is understood to simply discard the result:
type VoidFn = () => void;declare const takesVoidFn: (f: VoidFn) => void;takesVoidFn(() => 42); // OK -- the number is just ignored by whoever calls f()And numeric enums keep one famously loose relationship with number in both directions — a plain number is assignable to an enum-typed variable, and an enum member is assignable to number, with no range check either way (this is the “weak range checking” the Objects, Arrays & Tuples lesson’s enum section flags as a real footgun, not a made-up one).
Fresh object literals get one extra check; everything else doesn’t
Section titled “Fresh object literals get one extra check; everything else doesn’t”The excess-property check — the thing that makes { x: 1, y: 2, z: 3 } an error when a function expects { x: number; y: number } — is not part of ordinary structural assignability. It’s a separate, narrower check that only runs on a fresh object literal: one written directly at the point where it’s being checked, with no variable in between:
// @expect-error TS2353interface Point { x: number; y: number }declare const takes: (p: Point) => void;
takes({ x: 1, y: 2, z: 3 }); // fresh literal -- excess property check firesinterface Point { x: number; y: number }declare const takes: (p: Point) => void;
const obj = { x: 1, y: 2, z: 3 };takes(obj); // NOT fresh -- ordinary structural assignability, extra props allowedBoth snippets compile the exact same { x, y, z } shape into the exact same Point-shaped parameter — the only difference is whether the object literal is written inline (fresh) or stored in a variable first (not fresh, structural rules only). This is exactly why “just assign it to a variable first” is the standard, if slightly odd-looking, way to bypass a genuine excess-property mistake when you actually mean to pass extra fields through.
Comparability: === has its own, separate check
Section titled “Comparability: === has its own, separate check”==/=== between two operands with no overlap at all is flagged as TS2367 — not an assignability error, a different relation entirely called comparability, which is deliberately looser than assignability (types don’t need to be assignable to each other to be comparable, only to share some possible value):
// @expect-error TS2367declare const x: "a" | "b";declare const y: "c" | "d";if (x === y) {}Neither "a" | "b" nor "c" | "d" is assignable to the other, but that’s not why this errors — plenty of non-assignable pairs (string vs "a") compare just fine. It errors because the two literal unions share zero possible values, so the comparison can only ever be false, which is almost always a typo (comparing the wrong variable, or a value that was supposed to update but didn’t) rather than a real check. TS2367 exists as its own guard specifically because assignability alone wouldn’t catch this: two unrelated-but-overlapping types compare fine, and two related-by-assignability types obviously compare fine — it’s specifically the “provably disjoint” case that’s almost certainly a bug.
Recursive types compare by structure, not by name
Section titled “Recursive types compare by structure, not by name”A recursive type alias refers to itself, which raises an obvious question: how does the checker compare two different recursive type names without looping forever?
type A = { a: A };type B = { a: B };
declare const av: A;const bv: B = av; // compiles -- zero errorsA and B are two distinct, separately-named recursive shapes, and TypeScript treats them as assignable to each other anyway (verified) — structural typing means names never mattered in the first place, so two recursive types with identical structure really are the same type as far as assignability is concerned. What stops the comparison from recursing forever is that the checker tracks in-progress comparisons: when it’s asked “is A assignable to B” and that exact question is already being answered somewhere higher up the same call stack (because comparing A.a to B.a requires comparing A to B again), it doesn’t recurse a second time — it provisionally assumes the answer is “yes, so far” for that in-flight pair and lets the outer comparison finish, only actually failing if some other, non-recursive part of the structure disagrees. This is what allows deeply (even infinitely) self-referential type shapes to compare in finite time at all, and it’s also why a genuinely different recursive shape (say, type C = { a: C; b: number }) correctly fails to match A, once the non-recursive b: number part of the comparison is reached.
Mental Model
Section titled “Mental Model”flowchart TD
start["Is Source assignable to Target?"] --> walk["For every property Target requires,
does Source have a compatible one?"]
walk --> ro["readonly on either side? Ignored -- not part of the comparison"]
walk --> fn{"Is this member a method or a property function?"}
fn -->|method shorthand| biv["Checked bivariantly (looser)"]
fn -->|property function| contra["Checked contravariantly under strictFunctionTypes (sound)"]
walk --> special{"Is Source or Target any, unknown, or never?"}
special -->|any| both["Assignable both ways -- the check is disabled"]
special -->|unknown| into["Assignable INTO it only"]
special -->|never| outof["Assignable OUT of it only"]
fresh["Is Source a FRESH object literal?"] -->|yes| excess["Extra check: no excess properties allowed"]
fresh -->|no, stored in a variable| walk Every rule in this lesson is a variation on one act: comparing two shapes property by property, driven by what the target requires. readonly never enters that comparison because it’s not a property of the value’s shape, it’s a restriction on the binding. Method bivariance and property contravariance are the same comparison applied to a function-shaped member, with the method form deliberately loosened. any/unknown/never are three different ways of short-circuiting the walk entirely, each in a different direction. The excess-property check and comparability (TS2367) are the two places the checker runs an additional, narrower rule on top of ordinary assignability, precisely because plain structural comparison wouldn’t catch what they’re for (a literal typo, an always-false comparison).
Trade-offs
Section titled “Trade-offs”| Rule | Benefit | Cost |
|---|---|---|
| Structural (property-by-property) assignability | Any two independently-defined types with the same shape just work together — no explicit “implements” ceremony | Two accidentally-identical types that were never meant to be interchangeable are, anyway |
readonly excluded from assignability | Keeps the assignability relation simple and purely shape-based | A readonly object can be silently handed to code that mutates it through a Mutable-typed alias, defeating the protection readonly looked like it offered |
| Method bivariance | Common generic-container and override patterns type-check without extra ceremony | The exact unsoundness strictFunctionTypes was built to prevent still gets through, as long as it’s spelled as a method instead of a property function |
| Fresh-literal excess check | Catches the single most common typo (a misspelled or extra property) right at the call site | Only fires on a literal written inline — the identical mistake through a variable is silent, which surprises people who don’t know the rule |
TS2367 comparability | Flags a comparison that can only ever be false — almost always a real bug | Occasionally fires on a genuinely-defensive check the author knows is currently unreachable but wants to keep for future-proofing |
Common Misconceptions
Section titled “Common Misconceptions”readonlymakes two types incompatible if one has it and the other doesn’t. It never participates in assignability at all — only the property’s value type is compared. Areadonlyguarantee can be silently discarded by assigning through a non-readonly-typed alias.- A method and a property holding the same function type are checked identically. They aren’t: a method signature (
f(x: T): void) is checked bivariantly; the exact same function type spelled as a property (f: (x: T) => void) is checked contravariantly, and only the latter is fully sound understrictFunctionTypes. - The excess-property check is part of normal structural typing. It’s a separate, narrower rule that only applies to object literals written directly at the checked position — store the same literal in a variable first and it vanishes, with no change to the literal’s actual shape.
any,unknown, andneverare just “loose” versions of each other. They break the assignability rule in three different, specific directions:anyboth ways,unknowninbound only,neveroutbound only. Confusing which one a boundary uses changes exactly what gets silently accepted versus rejected there.
Reading AI-generated code
Section titled “Reading AI-generated code”Assignability gaps are some of the quietest bugs in generated code, because every one of them compiles clean:
- A parameter or return type widened to
any(directly, or via an unannotated third-party callback, or viaJSON.parse) disables assignability checking in both directions at that boundary — check whether the surrounding code narrows or validates it before trusting anything downstream. - A callback parameter typed as a bare function property (
onError: (e: Error) => void) that a generated adapter assigns a narrower-typed handler to is exactly the property-contravariance case from this lesson — it’s a realTS2322if it happens, so if you see it compiling, check which form (method vs. property) is actually in play. - Two values compared with
===where the types look unrelated but the comparison compiles: that’s fine (comparability is looser than assignability on purpose) — but iftscreportsTS2367on a generated comparison, that’s a strong signal of a copy-paste variable-name mistake, not a false positive to silence.
🐛 Spot the bug
interface Config { readonly apiUrl: string; readonly retries: number }
function withDefaults(overrides: Partial<Config>): Config {return { apiUrl: "https://api.example.com", retries: 3, ...overrides };}
function resetRetries(config: { apiUrl: string; retries: number }) {config.retries = 0;}
const config = withDefaults({});resetRetries(config);console.log(config.retries);Show the answer
This compiles with zero errors, and it does mutate a value the Config type calls readonly. resetRetries’s parameter type { apiUrl: string; retries: number } is structurally compatible with Config in every way readonly doesn’t affect (same property names, same value types), and because readonly plays no role in assignability at all, passing a Config where that plain mutable shape is expected is completely legal. Once inside resetRetries, config.retries = 0 is a perfectly ordinary write to a property the parameter’s own type never marked readonly — the original Config-typed variable now has its “protected” field silently changed. The fix isn’t a cast or an assertion; it’s recognizing that readonly is a compile-time reminder for code that references the value through the Config type, not a guarantee that survives being handed to code written against a structurally-identical but unmarked type.