Home › JavaScript › TS2322 Not Assignable: TypeScript Type Fix
Intermediate 6 min · September 23, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 25, 2026
last updated
1,950
articles · all by Naren
Before you start⏱ 12 min
  • ✓Basic TypeScript types, interfaces, and unions
  • ✓A project with strict mode enabled
  • ✓Comfort reading tsc error output in the terminal
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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.
✦ Definition~90s read
What is TS2322 Type Not Assignable Fix?

TS2322, Type 'X' is not assignable to type 'Y', is TypeScript's structural compatibility verdict. The checker compares the source shape against the target shape member by member, and when any leaf disagrees — a string where a literal was required, a missing property, a null under strictNullChecks — the whole assign fails.

★
Think of TS2322 as a shape-sorting toy.

It's not about names or imports; two differently named interfaces with identical members assign freely, while identically named ones with one divergent leaf do not.

The usual triggers cluster into four buckets. Width mismatches pit wide primitives against narrow literals: string versus 'paid', number versus 200. Shape gaps mean a required property is absent or optionally mismatched. Context gaps arise when the source is any, unknown, or parsed JSON with no proven shape.

Refactor drift happens when an interface gains a field and dozens of construction sites lag behind.

What it is NOT keeps you calm. It's not a runtime bug yet — it's the compiler refusing to create one. It's not fixed by casting, which trades the error for a hidden crash. It's not proof your design is wrong; often the types are right and one site needs narrowing. And it's not random: the quoted leaf in the message is the exact contract clause that failed.

Think of it as a gate check. The target type posts its entry rules, the source presents its papers, and TS2322 stamps the specific line that didn't match so you can correct that line.

Plain-English First

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.

read-error.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
type OrderStatus = 'pending' | 'paid' | 'shipped';

// Error: Type 'string' is not assignable to type 'OrderStatus'.
declare const fromApi: string;
const s1: OrderStatus = fromApi;

// Fixed: narrow before assigning.
function isOrderStatus(v: string): v is OrderStatus {
  return v === 'pending' || v === 'paid' || v === 'shipped';
}
if (isOrderStatus(fromApi)) {
  const s2: OrderStatus = fromApi; // OK
}
Try it live
📊 Production Insight
Log the exact TS2322 leaf in the fix commit message. Future readers can see which property mismatched without reconstructing the error from a cast diff.
🎯 Key Takeaway
Read the innermost quoted type first — it names the failing leaf. Hover both sides, get the full compiler text, and fix that leaf only.

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.

structural.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface User {
  name: string;
  role: 'admin' | 'member';
  address?: { zip: string };
}

// Missing required member: fails.
const a = { name: 'Ada' };
// const u1: User = a; // TS2322: Property 'role' is missing.

// Complete shape: passes without ever naming User.
const b = { name: 'Ada', role: 'admin' as const };
const u2: User = b; // OK

// Excess property check only on fresh literals.
// const u3: User = { name: 'Ada', role: 'member', extra: 1 }; // Error
const tmp = { name: 'Ada', role: 'member' as const, extra: 1 };
const u4: User = tmp; // OK structurally (extra ignored)
Try it live
📊 Production Insight
Shared domain interfaces should live in one module. When three teams redefine User locally, structural drift causes TS2322 at every integration point.
🎯 Key Takeaway
Compatibility is member-by-member shape comparison. Check required props, optionality, and literal widths — not interface names.

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.

narrow-wide.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type OrderStatus = 'pending' | 'paid' | 'shipped';

// Widened: let infers string.
let w = 'paid';
// const bad: OrderStatus = w; // TS2322 if reassigned elsewhere

// Narrow at birth.
const n = 'paid' as const;
const good1: OrderStatus = n; // OK

let kept: OrderStatus = 'paid'; // OK: constrained
kept = 'shipped'; // OK: still within union
// kept = 'processing'; // Error: honest signal to grow the union

const list = ['paid', 'shipped'] as const;
type FromList = (typeof list)[number]; // 'paid' | 'shipped'
Try it live
📊 Production Insight
Default fetch wrappers that return string for status fields manufacture this error at every call site. Type the wrapper once with the union and the errors vanish.
🎯 Key Takeaway
Wide sources can't flow into narrow targets. Narrow the source with const and annotations, or widen the target when the domain really grew.

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.

guards.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  if (s.kind === 'circle') {
    return Math.PI * s.radius ** 2; // narrowed to circle
  }
  return s.side ** 2; // narrowed to square
}

function isShape(v: unknown): v is Shape {
  if (typeof v !== 'object' || v === null) return false;
  const o = v as Record<string, unknown>;
  return o['kind'] === 'circle' || o['kind'] === 'square';
}

declare const raw: unknown;
if (isShape(raw)) {
  area(raw); // OK: proven, not cast
}
Try it live
📊 Production Insight
Boundary predicates double as runtime monitors. Log the else branch and you'll discover upstream contract changes weeks before users report them.
🎯 Key Takeaway
Don't claim the type with a cast — prove it with a guard. Predicates narrow the true branch and give failures a handled path.

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.

satisfies-demo.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
type Theme = { mode: 'dark' | 'light'; scale: number };

// Checked yet still narrow: mode stays 'dark', not string.
const theme = { mode: 'dark', scale: 2 } satisfies Theme;
const m: 'dark' | 'light' = theme.mode; // OK

// Typo caught at the declaration, not at a later assign.
// const bad = { mode: 'dakr', scale: 2 } satisfies Theme; // Error

// Annotation alone would widen and lose the literal.
const wide: Theme = { mode: 'dark', scale: 2 };
// wide.mode is string-wide Theme['mode'], less precise than theme.mode
Try it live
📊 Production Insight
Config tables without satisfies are a top source of distant TS2322 reports. Adding the operator moves the error to the declaration where the typo lives.
🎯 Key Takeaway
Use satisfies on constants that must match a contract: you get checking now plus narrow literals later.

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.

⚠ Never Cast Away TS2322 at a Boundary
as Target at a fetch or parse site hides every future shape change. Use unknown plus a predicate so unexpected payloads fail loudly in handling, not silently in rendering.
📊 Production Insight
Teams that grep for as any during review cut boundary bugs sharply. Each removed cast is a future production incident that now fails at compile time instead.
🎯 Key Takeaway
TS2322 clusters around an upstream any. Seal the entry with unknown and a guard, and the downstream errors disappear together.
● Production incidentPOST-MORTEMseverity: high

The Status String That Broke Checkout for 3 Hours

Symptom
Checkout showed a blank status step for roughly 20% of orders after a routine deploy. Payments still processed, so no revenue alert fired. Support got 90 tickets about stuck checkouts. The frontend logged no exception because the status fell through a switch with no default, rendering nothing. The team insisted the build was green, because the cast had made it green.
Assumption
The team assumed a backend change that returned status as a plain string was compatible because the field name hadn't changed. A developer saw TS2322 on the assignment, assumed the checker was being strict about literals, and added as OrderStatus to unblock the pipeline. Nobody recorded the cast as debt, and review treated it as a routine type fix.
Root cause
The API shifted from returning 'pending' | 'paid' | 'shipped' to returning string, and the frontend type stayed a narrow union. The assignment string to OrderStatus correctly failed with TS2322 at the boundary file. The cast bypassed the check, so an unexpected 'processing' value flowed into a switch that only handled the three known cases. The UI rendered an empty step, users abandoned checkout, and the mismatch lived for three hours until an engineer reproduced it with the network tab open and saw the unhandled value.
Fix
The cast was removed and the boundary was typed as unknown, then validated with a type predicate isOrderStatus that narrows only known literals and routes anything else to an error state. The OrderStatus union gained an explicit 'processing' member after confirming with the backend team, plus a default branch that logs and shows a retry message. CI now runs tsc --noEmit with noUncheckedIndexedAccess and fails on any as-cast in boundary files unless a comment links an approved exception.
Key lesson
  • 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.
Production debug guideFive checks that trace each failed assign to its leaf mismatch and fix it without casts.5 entries
Symptom · 01
TS2322 underlines an assignment or argument
→
Fix
Read the quoted leaf first: Type 'string' is not assignable to type '"paid"' names the exact spot. Run npx tsc --noEmit to see the full message, then hover the source and target in your editor. Fix the leaf — narrow the source or widen the target — and re-run npx tsc --noEmit to confirm zero errors.
Symptom · 02
TS2322 on an object literal with a nested property
→
Fix
Expand the object in the error: Property 'address.zip' is missing means the target requires it. Run npx tsc --noEmit --pretty false 2>&1 | grep TS2322 to list every site, then add the missing prop or mark it optional (zip?: string) if the contract allows. Verify with npx tsc --noEmit.
Symptom · 03
TS2322 says string is not assignable to a literal union
→
Fix
Check where the string widened: a let s = 'paid' widens to string. Change it to const s = 'paid' or annotate let s: OrderStatus = 'paid'. If the value comes from JSON, validate with a predicate. Confirm with npx tsc --noEmit and grep for remaining TS2322 lines.
Symptom · 04
TS2322 involving any spreading through assigns
→
Fix
Find the leak with npx tsc --noEmit plus grep -rn ': any' src-boundary to locate casts. Replace the boundary type with unknown, add a guard function (value is Order), and let narrowing flow downstream. Re-run npx tsc --noEmit; the cascade of secondary errors should collapse.
Symptom · 05
TS2322 after a refactor renamed or moved a field
→
Fix
Run git diff --name-only HEAD~1 to list renamed files, then npx tsc --noEmit to map every broken assign. Update the shared interface once at its definition rather than patching each site with a cast. Run npm test plus npx tsc --noEmit to confirm the refactor is complete.
TS2322 Causes Compared
Root CauseHow to ConfirmFixPrevention
Wide string or number assigned to a literal unionError quotes string vs 'paid' | 'shipped'; source uses let or untyped APINarrow with const, annotation, or a predicate at the boundaryType wrappers with unions; prefer const and as const for literals
Missing or optional-mismatched propertyError names Property 'zip' is missing or undefined not assignableAdd the prop or mark it optional (zip?: string) if contract allowsShare one interface; review optional flags when contracts change
Detached handler or unvalidated JSON without contextSource is unknown, any, or JSON.parse with no guardAdd a type predicate and branch; use satisfies on constantsParse into validated interfaces; use satisfies on config tables
Upstream any leaking through a castgrep finds as any near the boundary; errors cluster downstreamReplace any with unknown plus narrowing; delete the castLint against as any in boundaries; CI fails on new explicit any
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
read-error.tstype OrderStatus = 'pending' | 'paid' | 'shipped';Reading TS2322
structural.tsinterface User {Structural Typing
narrow-wide.tstype OrderStatus = 'pending' | 'paid' | 'shipped';Narrow vs Wide Types
guards.tstype Shape =Type Guards and Predicates
satisfies-demo.tstype Theme = { mode: 'dark' | 'light'; scale: number };Satisfies Operator

Key takeaways

1
TS2322 is structural
read the quoted leaf to find the exact property or literal that mismatched.
2
Wide sources can't flow into narrow targets
narrow with const, annotations, or guards.
3
Prove uncertain values with predicates; casts hide the next contract change.
4
Use satisfies on constants to check shape without widening literals.
5
Seal upstream any with unknown plus validation so downstream errors collapse.
6
Only widen the target union when the domain truly grew, not to silence the checker.

Common mistakes to avoid

5 patterns
×

Silencing TS2322 with as any or as Target

Symptom
Error clears locally, then production crashes on renamed fields the cast hid, with no compiler signal at the real change.
Fix
Replace the cast with a guard: if (isOrderStatus(v)) use it, else handle. The checker then verifies every future shape change.
×

Declaring literals with let and letting them widen

Symptom
let status = 'paid' infers string, so every assign to a union fails even though the value looks right.
Fix
Use const, annotate at birth (let s: OrderStatus = 'paid'), or add as const so literals stay narrow.
×

Annotating constants with the interface and losing precision

Symptom
const t: Theme widens mode to string, so later literal-specific assigns fail or autocomplete degrades.
Fix
Write const t = {...} satisfies Theme to check the shape while keeping the inferred literal types.
×

Leaving fetch and JSON.parse results as any

Symptom
No error at the boundary, then a spray of TS2322 across features when the payload shape shifts upstream.
Fix
Type the boundary as unknown, define the expected interface, and validate once with a predicate before use.
×

Editing the target union instead of reading the leaf

Symptom
Unions grow with string, props turn optional, and the type stops meaning anything while the real mismatch persists.
Fix
Quote the failing leaf first, fix that property or width, and only widen the target when the domain truly grew.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does TS2322 mean, and what's the first thing you read in the messag...
Q02SENIOR
Why does let s = 'paid' fail where const s = 'paid' passes against a uni...
Q03SENIOR
When do you narrow the source versus widen the target?
Q04SENIOR
How does satisfies differ from annotating with the interface?
Q05SENIOR
A boundary file uses as Target and TS2322 clusters downstream. How do yo...
Q01 of 05JUNIOR

What does TS2322 mean, and what's the first thing you read in the message?

ANSWER
It means a value's type isn't assignable to the target's type under structural checking. I read the innermost quoted leaf — the specific property or literal pair — because that names the exact mismatch to fix rather than the whole object.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What does TS2322 actually mean?
02
Should I fix it with as Target?
03
Why does a plain string fail against my union?
04
What does satisfies do for this error?
05
Why does TS2322 appear far from my change?
06
How do I stop TS2322 from recurring?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 25, 2026
last updated
1,950
articles · all by Naren
🔥

That's TypeScript. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
React Invalid Hook Call Fix
16 / 16 · TypeScript
Next
Circular Structure JSON Fix
→