Suggest an editImprove this articleRefine the answer for “Type narrowing in TypeScript”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Type narrowing** in TypeScript refines a union type to a specific subtype by analyzing control flow checks you write. ```typescript function process(value: string | number) { if (typeof value === "string") { value.toUpperCase(); // value: string } else { value.toFixed(2); // value: number } } ``` **Key point:** All narrowing happens at compile time. The checks run as plain JavaScript with zero runtime overhead.Shown above the full answer for quick recall.Answer (EN)Image**Type narrowing** is how TypeScript refines a variable from a broad union type down to a specific subtype by analyzing the runtime checks you already write. ## Theory ### TL;DR - Think of it like a bouncer checking IDs: a `string | number` parameter becomes just `string` once you prove it with `typeof` - TypeScript tracks control flow through `if/else`, `switch`, and early returns, shrinking types per branch - Six main techniques: `typeof`, `instanceof`, `in`, equality checks, truthiness checks, and type predicates (`value is Type`) - Discriminated unions (a shared `kind` or `status` literal field) are the cleanest pattern for complex state - All narrowing is compile-time only. Zero runtime cost, the checks run as plain JavaScript ### Quick example ```typescript function logValue(input: string | number) { // input: string | number here if (typeof input === "string") { // TypeScript narrows: input is string input.toUpperCase(); // OK } else { // input: number input.toFixed(2); // OK } } ``` TypeScript sees `typeof input === "string"` and intersects the union with `string` in that branch. The `else` branch gets whatever is left: `number`. Hover over `input` in your IDE inside each branch and the type changes. ### How the compiler narrows TypeScript's compiler runs control flow analysis on the AST. For every branch (`if`, `else`, `switch` case, early `return`), it recalculates what type a variable can be at that point. The original type never changes. TypeScript just knows more about it inside that scope. No runtime cost. Everything is erased when the code compiles. V8 sees plain JavaScript conditionals, nothing special. ### Six narrowing techniques **`typeof`** for primitive unions: ```typescript function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") return val.trim(); return val.toLocaleString(); // val: number here } ``` **`instanceof`** for class instances: ```typescript function handleEvent(event: MouseEvent | KeyboardEvent) { if (event instanceof MouseEvent) { console.log(event.clientX); // event: MouseEvent } else { console.log(event.key); // event: KeyboardEvent } } ``` **`in` operator** for interface shapes: ```typescript interface Circle { radius: number; } interface Square { size: number; } function getArea(shape: Circle | Square) { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape: Circle } return shape.size ** 2; // shape: Square } ``` **Equality narrowing** for discriminated unions: ```typescript type Result = | { status: "ok"; data: string } | { status: "err"; message: string }; function handle(result: Result) { if (result.status === "ok") { console.log(result.data); // result: { status: "ok"; data: string } } else { console.log(result.message); } } ``` **Truthiness narrowing** removes `null`/`undefined`: ```typescript function greet(name: string | null) { if (name) { console.log(name.toUpperCase()); // name: string } } ``` **Type predicates** for reusable guards: ```typescript function isString(val: unknown): val is string { return typeof val === "string"; } if (isString(input)) input.toUpperCase(); // input: string ``` ### When to use which technique - Primitive union (`string | number`) → `typeof` - Class instances → `instanceof` - Interface or object shapes → `"prop" in obj` - State machines, API responses, Redux actions → discriminated unions with equality check - Complex validation, unknown API data → type predicates - `null`/`undefined` checks → prefer `=== null` over truthiness for precision ### Common mistakes **Using `== null` instead of `=== null`:** ```typescript // Wrong - TypeScript does NOT narrow here function bad(value: string | null) { if (value == null) return; value.length; // Error: value is still string | null } // Correct function good(value: string | null) { if (value === null) return; value.length; // value: string } ``` The loose `== null` catches both `null` and `undefined`, so TypeScript can't confidently narrow `string | null` to `string`. Use strict `===`. **Accessing a property without `in` first:** ```typescript interface A { x: number; } interface B { y: string; } // Wrong - TypeScript errors: x doesn't exist on B function wrong(p: A | B) { if (p.x) { ... } } // Correct function correct(p: A | B) { if ("x" in p) { /* p: A */ } } ``` **Assuming callbacks preserve narrowing:** ```typescript function process(value: string | null) { if (value !== null) { setTimeout(() => { // TypeScript may flag this: value could have changed console.log(value.toUpperCase()); }, 1000); } value = null; // changed after the check } ``` Capture the narrowed value in a `const` before the callback: ```typescript const v = value; // v: string setTimeout(() => console.log(v.toUpperCase()), 1000); ``` **`instanceof` on primitives:** ```typescript // Wrong - primitives are not class instances if (value instanceof String) { ... } // Correct if (typeof value === "string") { ... } ``` **Forgetting to check optional fields after narrowing the outer union:** ```typescript type Action = { type: "LOAD"; data?: string } | { type: "UPDATE" }; function handle(action: Action) { if (action.type === "LOAD") { // data is still string | undefined here! // action.data.trim(); // Error if (action.data) { action.data.trim(); // OK: string } } } ``` TypeScript narrows the union but does not auto-narrow nested optionals. Each level needs its own check. ### Real-world usage - React: discriminated props (`if (props.kind === "primary")` in Chakra UI-style polymorphic components) - Redux Toolkit: action narrowing in reducers via `switch (action.type)` - tRPC/Express middleware: `if ("userId" in req.body)` for request body variants - Zod: `if (result.success) { const data = result.data; }` after `.safeParse()` - API responses: `type ApiResponse<T> = { success: true; data: T } | { success: false; error: string }` ### Follow-up questions **Q:** What is the difference between type narrowing and a type assertion (`as string`)? **A:** Narrowing proves the type through control flow. TypeScript checks your logic and only narrows when it can verify correctness. An assertion is a promise to the compiler with no proof behind it. If the promise is wrong, you get a runtime error. **Q:** Write a type guard function for an API user object. **A:** ```typescript function isUser(obj: unknown): obj is { name: string; email: string } { return ( typeof obj === "object" && obj !== null && "name" in obj && "email" in obj ); } ``` **Q:** Why might TypeScript fail to narrow a union of objects when the shared property is typed as `string` instead of a literal? **A:** If the discriminant is `string` rather than `"loading" | "success" | "error"`, TypeScript can't tell the variants apart. Use literal types or `as const` on the values to get precise matching. **Q:** Does narrowing work across `await`? **A:** Yes, if the variable is not reassigned between the check and the `await`. TypeScript tracks it through async control flow as long as nothing mutates it in between. **Q:** How do you make a switch-based reducer exhaustive using `never`? **A:** ```typescript default: const _check: never = action; return _check; ``` If you add a new action type and forget to handle it, TypeScript errors on the `never` assignment. The default case becomes a compile-time safety net. ## Examples ### Basic: Primitive union with early return ```typescript function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") { return val.trim(); // val: string } return val.toLocaleString(); // val: number } format(" hello "); // "hello" format(1234567); // "1,234,567" format(null); // "N/A" ``` Three types, three branches, each clearly narrowed. The early return for `null` is the most common pattern in production code. I use it as a default in every utility function that accepts optional values, because it keeps the happy path flat and readable. ### Intermediate: React component with discriminated props ```typescript interface ButtonProps { kind: "button"; label: string; onClick: () => void; } interface LinkProps { kind: "link"; label: string; href: string; } type Props = ButtonProps | LinkProps; function InteractiveElement(props: Props) { if (props.kind === "button") { // props: ButtonProps - onClick exists here return <button onClick={props.onClick}>{props.label}</button>; } // props: LinkProps - href exists here return <a href={props.href}>{props.label}</a>; } ``` The `kind` field is the discriminant. TypeScript narrows to each exact interface inside the matching branch. Trying to access `props.href` in the first branch is a compile error. This pattern appears in nearly every component library that supports polymorphic rendering. ### Advanced: Redux-style reducer with nested narrowing ```typescript type Action = | { type: "LOAD"; status: "loading" | "success" | "error"; data?: string } | { type: "UPDATE"; value: number }; interface AppState { content: string; status: string; count: number; } function reducer(state: AppState, action: Action): AppState { if (action.type === "LOAD") { // action: { type: "LOAD"; status: ...; data?: string } if (action.status === "success" && action.data) { // Without '&& action.data', data is still string | undefined. // TypeScript won't let you assign it without the explicit check. return { ...state, content: action.data }; } return { ...state, status: action.status }; } // action: { type: "UPDATE"; value: number } return { ...state, count: action.value }; } ``` The tricky part: `data` is optional (`data?: string`). Even after narrowing `action.type === "LOAD"` and `action.status === "success"`, you still need `&& action.data` to push the type from `string | undefined` to `string`. TypeScript does not auto-narrow nested optionals. That is the edge case that catches most mid-level developers in code review.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.