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'.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
qtyis astringwhere anumberis required. - Read the lines above it as a path:
lines, then an element of the array, thenqty. - Decide which side is wrong: the data or the declared type. Fix that side.
- If the source says
stringbut the target is a union of literals like"GET" | "POST", the value was widened; annotate it where it is created, or useas constorsatisfies. - If the source includes
| undefined, narrow or provide a default before assigning. - Do not reach for
as Orderoras unknown as Orderunless 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;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);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'.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 methodssatisfies 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;
}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'.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 };
}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; }'.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 };
}),
};
}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" };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".