React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreacttype-guardsnarrowingassertionsatisfiesis

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, and object.
  • Custom type guards use the param is Type return type annotation. When the function returns true, TypeScript narrows the parameter to Type in the calling scope.
  • in operator narrows based on property existence. "email" in account narrows to the union member(s) that have an email property.
  • instanceof narrows class instances: if (error instanceof TypeError) narrows to TypeError.
  • satisfies validates 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 the if block).

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 error

TypeScript Notes

  • Type guards narrow in both the if and else branches. In the else of isAdmin(account), TypeScript knows account is User.
  • satisfies was 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 if block. This makes them powerful for early validation at the top of a function.
  • Nullish checks (if (x), if (x != null)) narrow out null and undefined but also narrow out falsy values like 0 and "". Use != null for precise null/undefined narrowing.

Gotchas

  • Custom type guards put correctness on you. TypeScript trusts your is predicate. If you write a buggy guard, TypeScript will be wrong about the narrowed type.
  • typeof null === "object" is a JavaScript quirk. Use value !== 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 narrow state to the success variant. Use state.status directly.

Alternatives

ApproachProsCons
Custom type guard (is)Reusable, readable, explicitGuard correctness is your responsibility
in operatorNo helper function neededOnly checks property existence, not value type
instanceofBuilt into JavaScriptOnly works with classes, not interfaces
satisfiesValidates without wideningDoes not create a reusable type
Assertion function (asserts)Narrows all subsequent codeMust throw, cannot return false
Zod .parse()Runtime + compile-time safetyExternal 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?
  • typeof narrows to: string, number, boolean, symbol, bigint, undefined, function, and object.
  • Inside the if (typeof value === "string") block, TypeScript knows value is string.
  • 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 Type return 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 if block 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?
  • satisfies validates that an expression conforms to a type without changing its inferred type.
  • A type annotation (const x: T = ...) widens the type to T.
  • satisfies preserves 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 exclude null.
  • 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 between status and state.
  • Use state.status directly 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 satisfies when you want validation AND preserved literal types.
Can type guards narrow in both the if and else branches?
  • Yes. In the else branch of if (isAdmin(account)), TypeScript knows account is User (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.