Ch. 2 · TypeScript

TS2322: Type 'X' Is Not Assignable to Type 'Y' in TypeScript

Fix TS2322 'Type X is not assignable to type Y' by reading the elaboration chain from the bottom and correcting the value, not casting.

~7 min readintermediateupdated Oct 4, 2026

The compiler stopped with a wall of indented text like this:

src/order.ts(5,14): error TS2322: Type '{ id: string; lines: { sku: string; qty: string; }[]; }' is not assignable to type 'Order'.
  Types of property 'lines' are incompatible.
    Type '{ sku: string; qty: string; }[]' is not assignable to type 'OrderLine[]'.
      Type '{ sku: string; qty: string; }' is not assignable to type 'OrderLine'.
        Types of property 'qty' are incompatible.
          Type 'string' is not assignable to type 'number'.
Text

TS2322 means a value’s type does not fit the type of the place you are putting it: a variable with an annotation, a property, or a function’s declared return type. The same check on a function argument is reported as TS2345 (Argument of type ... is not assignable to parameter of type ...). The indented lines are an elaboration: TypeScript walked into the structure and is telling you exactly which nested member failed.

Quick fix checklist

  • Jump to the last indented line first: here qty is a string where a number is required.
  • Read the lines above it as a path: lines, then an element of the array, then qty.
  • Decide which side is wrong: the data or the declared type. Fix that side.
  • If the source says string but the target is a union of literals like "GET" | "POST", the value was widened; annotate it where it is created, or use as const or satisfies.
  • If the source includes | undefined, narrow or provide a default before assigning.
  • Do not reach for as Order or as unknown as Order unless you have validated the data some other way.

Before you start

Run npx tsc --noEmit --pretty false to get the plain text above; editors show the same chain in a hover. Neighbouring codes come from the same check but have their own wording: TS4104 for readonly arrays, TS2353 for excess properties in an object literal, and TS2345 for arguments.

Union members can print in a different order between versions: TypeScript 7.0 sorts them deterministically, so you may see '"dark" | "light"' where 5.9 printed '"light" | "dark"'. The meaning is identical. Examples here were compiled with 7.0 and 5.9 using strict: true.

Why it happens

TypeScript’s type system is structural. Assigning source to target succeeds when every property the target requires exists in the source with a compatible type, recursively. There is no nominal “is this an Order” check; there is only “does this have the right shape”. When the check fails deep inside a structure, the compiler reports the outer mismatch and then elaborates one level per line until it reaches the leaf that broke.

The target type is something you declared. The source type is usually inferred, and inference has rules that surprise people. The most common one is literal widening: let method = "GET" infers string, because a let can be reassigned to any string later. Properties of an object literal stored in a variable widen the same way, because object properties are mutable. So const base = { method: "GET" } gives base.method the type string, which is not assignable to "GET" | "POST". Writing the same object inline in a call works, because the parameter type provides context while the literal is being typed and the literal is never widened.

Step-by-step walkthrough

Step 1: Reproduce and read bottom-up

interface OrderLine { sku: string; qty: number }
interface Order { id: string; lines: OrderLine[] }

const fromApi = { id: "o1", lines: [{ sku: "A", qty: "2" }] };
export const order: Order = fromApi;
TypeScript

The first line of the error compares two huge object types and tells you nothing useful. The last line, Type 'string' is not assignable to type 'number', is the actual mismatch, and Types of property 'qty' are incompatible names where it lives. Long chains are the compiler being helpful; read them from the bottom.

Step 2: Decide which side is wrong

qty: "2" is a string because the JSON payload sends it that way. The domain needs a number for arithmetic, so the value needs converting. In other cases the type is stale (a field became optional in the API) and the annotation should change instead. Editing whichever side is more convenient is how bugs get in.

Step 3: Fix literal widening at the source

type Method = "GET" | "POST";
interface RequestConfig { url: string; method: Method; retries?: number }
declare function send(config: RequestConfig): void;

let method = "GET";
const viaVariable: RequestConfig = { url: "/api", method };

const base = { url: "/api", method: "GET" };
send(base);
TypeScript
src/request.ts(6,51): error TS2322: Type 'string' is not assignable to type 'Method'.
src/request.ts(9,6): error TS2345: Argument of type '{ url: string; method: string; }' is not assignable to parameter of type 'RequestConfig'.
  Types of property 'method' are incompatible.
    Type 'string' is not assignable to type 'Method'.
Text

Three fixes, each with a different trade-off:

const a: RequestConfig = { url: "/api", method: "GET" };              // annotate: a is exactly RequestConfig
const b = { url: "/api", method: "GET" } as const;                     // readonly, keeps literal types
const c = { url: "/api", method: "GET" } satisfies RequestConfig;      // checked, keeps the inferred type
let m: Method = "GET";                                                 // a let that only holds valid methods
TypeScript

satisfies is often the best choice for configuration objects: it reports mistakes at the definition and still lets later code see the precise inferred type.

Step 4: Handle unions, undefined and generics

declare const maybeName: string | undefined;
export const name: string = maybeName;

export function parsePort(v: string): number {
  return v ? Number(v) : undefined;
}
TypeScript
src/misc.ts(2,14): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
  Type 'undefined' is not assignable to type 'string'.
src/misc.ts(5,26): error TS2322: Type 'undefined' is not assignable to type 'number'.
Text

For a union source, every member must fit the target; the elaboration names the one that doesn’t. The second error points at the undefined branch itself because TypeScript 5.8 started checking each branch of a conditional in a return separately; older versions reported Type 'number | undefined' is not assignable to type 'number' on the whole expression. Narrow first, or return number | undefined if absence is a real outcome.

Generic code produces the variant people find hardest to read:

export function withDefaults<T extends { id: string }>(partial: Partial<T>): T {
  return { id: "new", ...partial };
}
TypeScript
src/defaults.ts(2,3): error TS2322: Type '{ id: string; } & Partial<T>' is not assignable to type 'T'.
  '{ id: string; } & Partial<T>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ id: string; }'.
Text

It means: your value fits { id: string }, but a caller could pick a T with more required properties, and a Partial<T> would not provide them. The fix is to change the signature (return { id: string } & Partial<T>, or require a full T), not to cast.

Step 5: Recognise the sibling errors

Assigning readonly string[] to string[] is reported as TS4104 (The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'). Accept readonly string[] in the function that receives it if it does not mutate the array. An extra property in a fresh object literal is TS2353 (Object literal may only specify known properties), covered in excess property checks.

Worked scenario

The order above comes from fetch. The API sends quantities as strings, and the checkout code multiplies them by prices. The honest model has two types: what arrives, and what the domain uses.

interface OrderLineDto { sku: string; qty: string }
interface OrderDto { id: string; lines: OrderLineDto[] }

export function toOrder(dto: OrderDto): Order {
  return {
    id: dto.id,
    lines: dto.lines.map((line) => {
      const qty = Number(line.qty);
      if (!Number.isInteger(qty) || qty <= 0) {
        throw new Error(`Invalid qty "${line.qty}" for ${line.sku}`);
      }
      return { sku: line.sku, qty };
    }),
  };
}
TypeScript

The conversion happens once, at the boundary, and it is checked at runtime. Everything after toOrder works with real numbers. If you had forced the assignment with fromApi as unknown as Order, the compiler would have accepted it and "2" * price would have been coerced silently, while "2" + 1 would have produced "21".

Common mistake

The tempting fix is a type assertion: const order = fromApi as Order. When TypeScript rejects even that (TS2352, Conversion of type ... may be a mistake), people escalate to as unknown as Order. Both tell the compiler to stop checking and change nothing at runtime. The string is still a string. Assertions are fine after you have validated data (with a schema library or a hand-written type guard), not instead of it.

The second mistake is widening the target to make the error vanish, for example changing method: Method to method: string. That compiles and removes the protection that caught the bug.

Verify the behavior

npx tsc --noEmit should exit with status 0. To keep the fix from regressing, add type-level tests: // @ts-expect-error makes the build fail if the next line stops being an error.

const ok: RequestConfig = { url: "/api", method: "POST" };

// @ts-expect-error: methods outside the union must be rejected
const bad: RequestConfig = { url: "/api", method: "PATCH" };
TypeScript

If someone later loosens method to string, the directive becomes unused and tsc reports TS2578 (Unused '@ts-expect-error' directive). Add a runtime test for toOrder that feeds qty: "abc" and expects the thrown error.

Interview exercise

Why does send({ url: "/api", method: "GET" }) compile, while const req = { url: "/api", method: "GET" }; send(req); fails with TS2345?

Answer and reasoning

In the inline call, the parameter type RequestConfig contextually types the object literal while it is being checked, so "GET" keeps its literal type and matches "GET" | "POST". In the second form, req is inferred on its own first. Object properties are mutable, so TypeScript widens "GET" to string (you could later write req.method = "DELETE"). When req reaches send, string is not assignable to the union.

Fixes include annotating req: RequestConfig, using satisfies RequestConfig to keep the precise inferred type, or as const to freeze it. A strong answer explains why widening is the right default for mutable objects and why a cast is wrong: it would also accept "DELETE".

Continue learning

More in TypeScript

esc