Type Narrowing
Recipe
Narrow TypeScript types at runtime using type guards, assertion functions, the in operator, and the satisfies keyword. Write code that gives TypeScript enough information to infer precise types in each branch.
Working Example
// typeof narrowing
function formatValue(value: string | number | boolean) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows: string
}
if (typeof value === "number") {
return value.toFixed(2); // TypeScript knows: number
}
return value ? "Yes" : "No"; // TypeScript knows: boolean
}
// Custom type guard with "is" predicate
type User = { kind: "user"; name: string; email: string };
type Admin = { kind: "admin"; name: string; permissions: string[] };
type Account = User | Admin;
function isAdmin(account: Account): account is Admin {
return account.kind === "admin";
}
function AccountBadge({ account }: { account: Account }) {
if (isAdmin(account)) {
return <span>Admin: {account.permissions.length} permissions</span>;
}
return <span>User: {account.email}</span>;
}// "in" operator narrowing
function renderAccount(account: Account) {
if ("permissions" in account) {
// TypeScript narrows to Admin
return <div>{account.permissions.join(", ")}</div>;
}
// TypeScript narrows to User
return <div>{account.email}</div>;
}// satisfies keyword - validate without widening
const ROUTES = {
home: "/",
about: "/about",
contact: "/contact",
} satisfies Record<string, string>;
// Type is preserved as { home: "/"; about: "/about"; contact: "/contact" }
// Not widened to Record<string, string>
type RouteKey = keyof typeof ROUTES; // "home" | "about" | "contact"Deep Dive
How It Works
- typeof narrowing works for primitive types:
string,number,boolean,symbol,bigint,undefined,function, andobject. - Custom type guards use the
param is Typereturn type annotation. When the function returnstrue, TypeScript narrows the parameter toTypein the calling scope. inoperator narrows based on property existence."email" in accountnarrows to the union member(s) that have anemailproperty.instanceofnarrows class instances:if (error instanceof TypeError)narrows toTypeError.satisfiesvalidates that an expression conforms to a type without changing its inferred type. This preserves literal types and specific shapes while ensuring correctness.- Assertion functions use
asserts param is Type. They throw if the condition is false and narrow the type for all subsequent code (not just theifblock).
Variations
Assertion function:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown) {
assertIsString(input);
// TypeScript knows input is string from here on
console.log(input.toUpperCase());
}Narrowing with Array.isArray:
function renderItems(data: string | string[]) {
if (Array.isArray(data)) {
return <ul>{data.map((item) => <li key={item}>{item}</li>)}</ul>;
}
return <p>{data}</p>;
}Discriminated union narrowing (switch):
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState<T>(state: AsyncState<T>, renderData: (data: T) => React.ReactNode) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return renderData(state.data); // Narrowed: data exists
case "error":
return <ErrorMessage error={state.error} />; // Narrowed: error exists
}
}satisfies with config objects:
type ColorConfig = Record<string, { bg: string; text: string }>;
const THEME = {
primary: { bg: "#3b82f6", text: "#ffffff" },
danger: { bg: "#ef4444", text: "#ffffff" },
success: { bg: "#22c55e", text: "#ffffff" },
} satisfies ColorConfig;
// THEME.primary is fully typed with literal values
// THEME.nonExistent would errorTypeScript Notes
- Type guards narrow in both the
ifandelsebranches. In theelseofisAdmin(account), TypeScript knowsaccountisUser. satisfieswas added in TypeScript 4.9. It is especially valuable for configuration objects, route maps, and constant definitions.- Assertion functions narrow for all code after the assertion, not just within an
ifblock. This makes them powerful for early validation at the top of a function. - Nullish checks (
if (x),if (x != null)) narrow outnullandundefinedbut also narrow out falsy values like0and"". Use!= nullfor precise null/undefined narrowing.
Gotchas
- Custom type guards put correctness on you. TypeScript trusts your
ispredicate. If you write a buggy guard, TypeScript will be wrong about the narrowed type. typeof null === "object"is a JavaScript quirk. Usevalue !== null && typeof value === "object"for object checks.- Assertion functions must throw (not return false) when the condition fails. TypeScript relies on the function never returning normally in the negative case.
- Destructuring before narrowing breaks the connection.
const { status } = state; if (status === "success") { state.data }does not narrowstateto the success variant. Usestate.statusdirectly.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
Custom type guard (is) | Reusable, readable, explicit | Guard correctness is your responsibility |
in operator | No helper function needed | Only checks property existence, not value type |
instanceof | Built into JavaScript | Only works with classes, not interfaces |
satisfies | Validates without widening | Does not create a reusable type |
Assertion function (asserts) | Narrows all subsequent code | Must throw, cannot return false |
Zod .parse() | Runtime + compile-time safety | External dependency |
FAQs
What is type narrowing in TypeScript?
- The process of refining a broad type to a more specific one within a code block.
- TypeScript tracks narrowing through control flow analysis (
if,switch,typeof,in, etc.). - After narrowing, you can access properties specific to the narrowed type without errors.
How does typeof narrowing work and what types does it support?
typeofnarrows to:string,number,boolean,symbol,bigint,undefined,function, andobject.- Inside the
if (typeof value === "string")block, TypeScript knowsvalueisstring. - It does not distinguish between arrays, dates, or other object subtypes.
What is a custom type guard and how do you write one?
function isAdmin(account: Account): account is Admin {
return account.kind === "admin";
}- Uses the
param is Typereturn type annotation. - When the function returns
true, TypeScript narrows the parameter to the specified type. - The correctness of the guard is your responsibility.
What is the difference between a type guard (is) and an assertion function (asserts)?
- A type guard narrows inside the
ifblock where it is checked. - An assertion function narrows for all code after the assertion call (not just within an
if). - Assertion functions must throw when the condition fails -- they cannot return
false.
How does the satisfies keyword differ from a type annotation?
satisfiesvalidates that an expression conforms to a type without changing its inferred type.- A type annotation (
const x: T = ...) widens the type toT. satisfiespreserves literal types and specific shapes while ensuring correctness.
Gotcha: Why does typeof null === "object" cause issues?
- This is a well-known JavaScript quirk.
- Using
typeof value === "object"alone does not excludenull. - Always write
value !== null && typeof value === "object"for safe object checks.
How does the in operator narrow types?
if ("permissions" in account) {
// TypeScript narrows to the union member that has "permissions"
account.permissions; // OK
}- Narrows based on property existence, not property value.
- No helper function needed, but less precise than a custom type guard.
Gotcha: Why does destructuring before narrowing break type inference?
const { status } = state; if (status === "success") { state.data }fails because TypeScript loses the connection betweenstatusandstate.- Use
state.statusdirectly in the check to maintain narrowing. - This is a common mistake with discriminated unions.
When should you use satisfies vs a type annotation for config objects?
// With satisfies: preserves literal types
const ROUTES = {
home: "/",
about: "/about",
} satisfies Record<string, string>;
// Type: { home: "/"; about: "/about" }
// With annotation: widens to Record<string, string>
const ROUTES2: Record<string, string> = { home: "/", about: "/about" };- Use
satisfieswhen you want validation AND preserved literal types.
Can type guards narrow in both the if and else branches?
- Yes. In the
elsebranch ofif (isAdmin(account)), TypeScript knowsaccountisUser(the other union member). - This works for all narrowing techniques:
typeof,in,instanceof, and custom guards.
What does Array.isArray() narrow to?
function renderItems(data: string | string[]) {
if (Array.isArray(data)) {
return data.map((item) => item); // data is string[]
}
return data; // data is string
}- Narrows the type to the array variant of the union.
- Works as a built-in type guard recognized by TypeScript.