TS2322 Not Assignable: TypeScript Type Fix
Narrow the mismatched value with a type guard to fix TS2322, then tighten the target type so future assigns fail fast instead..
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Basic TypeScript types, interfaces, and unions
- ✓A project with strict mode enabled
- ✓Comfort reading tsc error output in the terminal
- TS2322 means the value's type isn't assignable to the target: read the error's quoted property to find the exact mismatch.
- Fix it by narrowing the source (type guard, as const, literal type) or widening the target (union, optional prop), never with a blind as any.
- Use satisfies to check an object matches a contract without widening it, so literals stay narrow and assigns stay safe.
- Stop any at the boundary: validate unknown JSON into an interface before it flows into typed code and triggers silent mismatches.
Think of TS2322 as a shape-sorting toy. The hole is cut for a triangle, and you're pushing a square through it. TypeScript isn't being picky — it's telling you the shapes don't match before you force it and break the toy. The error message names the exact corner that sticks out: a missing property, a string where only 'red' was allowed, or a null you didn't expect. You either reshape the block or recut the hole.
You assign a value, hit save, and TypeScript stops you cold: Type 'X' is not assignable to type 'Y'. Nothing runs. The code looks right. The property names match. Yet the checker insists the shapes differ, and the message quotes a nested property you didn't expect.
The reflex is to silence it with as any or as unknown as Target. That clears the squiggle and deletes the protection. Weeks later a refactor renames a field, the cast hides the break, and production throws cannot read properties of undefined where the compiler should have warned you.
TS2322 is structural, not nominal. It compares shapes member by member, and it fails on the first incompatible leaf: a wide string against a narrow literal, a missing optional flag, a null under strictNullChecks. Learning to read that leaf is the whole skill.
This guide shows how to read the mismatched property, when to narrow the source versus widen the target, how guards and satisfies prove safety, and where any leaks in. You'll fix the error in minutes and keep the check working for every future assign.
Reading TS2322: Which Property Actually Mismatched
TS2322 messages look noisy, but they name the leaf that failed if you read from the inside out. Type 'string' is not assignable to type '"paid" | "shipped"' tells you the value is wide and the target is narrow. Property 'zip' is missing in type X tells you the source lacks a required member. Types of property 'user.role' are incompatible tells you to drill one level deeper into role, not to rework the whole object.
Your first move is always to isolate the two sides. Hover the source variable to see its inferred type, hover the target parameter or annotation to see the expected type, and compare leaf by leaf. Editors truncate long unions, so run the compiler directly for the full text: the terminal shows the complete expected versus actual pair that the hover may clip.
Don't rewrite either side until you've quoted the leaf aloud: source gives string, target wants the literal 'paid'. That sentence dictates the fix. A missing property means you add it or mark it optional. A wide-to-narrow failure means you narrow the source. An extra-property complaint on a literal means you check excess property rules versus a real mismatch.
Teams that skip this reading step thrash. They widen the target union, add optional flags, and sprinkle casts until the error moves somewhere else. Reading the leaf first turns TS2322 from a wall of red into a one-line work order.
Structural Typing: Why Matching Names Isn't Enough
TypeScript uses structural typing: two types are compatible when their members are compatible, regardless of declared names. An interface User with name: string and role: string accepts any object that supplies those members with those types, even if it was never declared as a User. That's powerful for reuse, and it's why TS2322 fires on shape differences you'd miss by scanning names.
Three structural rules cause most surprises. Missing properties fail: the target requires address, the source lacks it, the assign dies. Extra properties on fresh literals fail under excess property checks: { name, role, extra: 1 } assigned to User errors, while the same object passed through a variable does not. Method and function members compare bivariantly or strictly depending on strictFunctionTypes, so callbacks with mismatched params fail in strict projects that felt loose before.
Optionality and readonly matter too. A target with zip?: string accepts a source without zip, but a source with zip: string | undefined assigned to a target wanting zip: string still fails under strictNullChecks. A readonly array can't receive a mutable one in some positions. These aren't quirks; they're the checker enforcing the contract the target promised its consumers.
Think in shapes, not labels. When TS2322 fires on objects, list the target's required members, tick off each one in the source, and the unticked box is your fix. You'll stop renaming interfaces and start completing shapes.
Narrow vs Wide Types: String Against Literal Unions
The most common TS2322 pairs a wide source against a narrow target: string versus 'paid' | 'shipped', number versus 200 | 404, string[] versus readonly ['a']. Widening happens silently: let status = 'paid' infers string because let allows reassignment, function params typed as string stay wide, and JSON.parse returns wide or any-flavored values. The target stays narrow because someone carefully wrote the union. The assign bridges wide to narrow, and the checker refuses.
You have two honest directions. Narrow the source when you know the value: const status = 'paid' keeps the literal, let status: OrderStatus = 'paid' constrains it at birth, and as const on objects preserves every leaf literal. Widen the target when the domain grew: add 'processing' to the union, or accept string when the consumer truly handles anything. Pick based on truth, not convenience: if the backend can send 'processing', the union is wrong and must grow.
Watch inference traps. Array literals widen: ['paid'] becomes string[], so annotate as OrderStatus[] or use as const. Object properties widen under let and mutable bindings. Return types widen unless annotated. Each widening point is a future TS2322 waiting for a narrow consumer.
The rule of thumb fits on a sticky note: producers should be as narrow as truth allows, consumers as wide as handling allows. When TS2322 bridges them, move the side that's lying.
Type Guards and Predicates: Prove It to the Checker
When the value's type is legitimately uncertain — parsed JSON, a query param, a union you must branch on — you can't annotate the truth into existence. You must prove it with a check the compiler understands. That's a type guard: typeof for primitives, instanceof for classes, in for property presence, and custom predicates (value is Target) for domain rules. After the guard passes in an if branch, the value narrows and the assign succeeds.
Custom predicates carry the most weight because they encode domain knowledge. isOrderStatus(v: string): v is OrderStatus checks membership in the union and tells the checker the true branch is OrderStatus. Array.isArray, discriminated-union switches on a kind field, and assertion functions (asserts value is Target) all play the same role: runtime evidence that justifies compile-time narrowing.
Guards beat casts on every axis that matters. A cast claims safety with no evidence and rots silently when the upstream shape changes. A guard tests evidence on every run, routes failures to handling, and keeps narrowing local to the branch where it was proven. The else branch is a feature: log the unexpected value, show a fallback, and you've turned a crash into telemetry.
Use guards at every untrusted boundary: fetch responses, localStorage reads, postMessage payloads, and URL params. Typed code inside the branch stays clean because the boundary did the proving once.
Satisfies Operator: Check Shape Without Widening
Objects declared for config or constants face a dilemma: annotate with the interface and lose literal precision, or leave unannotated and lose checking. satisfies resolves it. const theme = { mode: 'dark' } satisfies Theme checks the object against Theme but keeps the inferred literal type 'dark' instead of widening to string. Later assigns that need the literal still pass, and typos against Theme still fail at the declaration.
Compare the alternatives. Annotation (const t: Theme = {...}) widens literals and hides excess detail you'll want later. A bare literal with no check drifts from the contract silently until some distant assign fails with a confusing TS2322. satisfies gives both: immediate validation plus preserved narrowness. It's the right default for route maps, style tokens, feature flags, and any table the compiler should verify once and remember precisely.
The operator also improves error locality. Without it, a malformed config surfaces as TS2322 at the use site three files away. With it, the error points at the malformed property in the declaration, where the fix belongs. That locality saves the archaeology of tracing a bad literal through layers of imports.
Adopt satisfies for every constant that must match a contract. You'll keep autocomplete, keep literal unions intact, and move TS2322 reports from consumers back to the definition that introduced the problem.
Any Leaking In: Stop Upstream Any Before It Spreads
Not every TS2322 is a genuine shape debate. Many are downstream symptoms of an any that entered upstream and poisoned inference. JSON.parse returns any, untyped fetch wrappers return any, and a single as any cast silences one error while manufacturing three stranger ones downstream. The checker reports TS2322 where the poison surfaces, not where it entered, so fixing the reported line with another cast just pushes the stain further.
Trace to the source instead. Search boundary files for : any, as any, and JSON.parse without validation. Replace the entry point with unknown: unknown forces every consumer to narrow before use, which sounds strict but actually localizes the work to one guard. From there, define the interface the data should satisfy, validate once with a predicate, and let precise types flow inward. The cluster of TS2322 errors collapses because the source is no longer shapeless.
Prevention is a review habit. Ban as any in boundary modules, require unknown plus a guard for parsed data, and type shared fetch helpers with generics so call sites supply the expected shape explicitly. Lint rules like no-explicit-any on new code keep the leak from reopening.
You'll know the leak is sealed when fixes get smaller. One predicate at the boundary replaces five casts across features, and future TS2322 reports point at real contract changes instead of any-flavored noise.
The Status String That Broke Checkout for 3 Hours
- Casts at data boundaries turn compile errors into runtime mysteries. Replace as Target with a predicate that proves the value matches, and route the else branch to visible handling.
- Narrow unions need a default branch in every consumer. When the backend adds a variant, the UI should log and degrade, not render blank silence.
- TS2322 at a boundary is a contract-change alarm. Treat it like a failed integration test: confirm the upstream shape before touching the local type.
| File | Command / Code | Purpose |
|---|---|---|
| read-error.ts | type OrderStatus = 'pending' | 'paid' | 'shipped'; | Reading TS2322 |
| structural.ts | interface User { | Structural Typing |
| narrow-wide.ts | type OrderStatus = 'pending' | 'paid' | 'shipped'; | Narrow vs Wide Types |
| guards.ts | type Shape = | Type Guards and Predicates |
| satisfies-demo.ts | type Theme = { mode: 'dark' | 'light'; scale: number }; | Satisfies Operator |
Key takeaways
Common mistakes to avoid
5 patternsSilencing TS2322 with as any or as Target
Declaring literals with let and letting them widen
Annotating constants with the interface and losing precision
Leaving fetch and JSON.parse results as any
Editing the target union instead of reading the leaf
Interview Questions on This Topic
What does TS2322 mean, and what's the first thing you read in the message?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's TypeScript. Mark it forged?
6 min read · try the examples if you haven't