DEV_NET_CORE
GET_STARTED
ReactTypeScript for React

Narrowing and control-flow analysis

Overview

Narrowing is TypeScript's ability to refine a broad type into a more specific type based on runtime checks and code flow. If a value starts as string | number, TypeScript will not let you call string-only or number-only methods until the code proves which case you are handling.

Code
function format(value: string | number) {
  if (typeof value === "number") {
    return value.toFixed(2);
  }

  return value.trim();
}

Control-flow analysis is how TypeScript follows branches, returns, assignments, guards, and unreachable paths to understand the most specific type at each point in a function. This matters heavily in React because components frequently handle nullable values, API responses, form inputs, discriminated UI states, reducer actions, route params, and event targets.

For interviews, this topic tests whether a developer can write safe TypeScript without fighting the compiler. A strong candidate should be able to explain how typeof, instanceof, equality checks, in, discriminated unions, custom type predicates, assertion functions, and exhaustive never checks help TypeScript prove correctness.

The practical goal is not to add random type annotations. The goal is to model possible states clearly and write checks that make impossible states hard to represent.

Core Concepts

What Narrowing Means

Narrowing means refining a value from a wider type to a narrower type.

Code
type UserId = string | number;

function normalizeUserId(id: UserId) {
  if (typeof id === "number") {
    return id.toString();
  }

  return id.trim();
}

Before the if, id is string | number. Inside the number branch, TypeScript treats it as number. After the branch returns, TypeScript knows the remaining path must be string.

This lets TypeScript validate ordinary JavaScript logic without requiring separate type-specific functions for every case.

Control-Flow Analysis

Control-flow analysis means TypeScript tracks how execution can move through your code.

Code
function getLabel(value: string | null) {
  if (value === null) {
    return "Unknown";
  }

  return value.toUpperCase();
}

After the early return, TypeScript knows value cannot be null.

The same idea works with branches that split and rejoin:

Code
function parseInput(input: string | number | boolean) {
  let result: string;

  if (typeof input === "boolean") {
    result = input ? "yes" : "no";
  } else if (typeof input === "number") {
    result = input.toFixed(0);
  } else {
    result = input.trim();
  }

  return result;
}

TypeScript follows assignments and branches to verify that result is assigned correctly before being returned.

typeof Guards

typeof is useful for primitive values and functions.

Code
function renderCount(count: number | string) {
  if (typeof count === "number") {
    return count.toLocaleString();
  }

  return count;
}

Common typeof results include:

  • "string"
  • "number"
  • "boolean"
  • "undefined"
  • "object"
  • "function"
  • "symbol"
  • "bigint"

Important JavaScript quirk:

Code
typeof null; // "object"

So this is not enough:

Code
function printNames(names: string[] | null) {
  if (typeof names === "object") {
    names.map((name) => name.toUpperCase()); // names can still be null.
  }
}

Better:

Code
function printNames(names: string[] | null) {
  if (Array.isArray(names)) {
    return names.map((name) => name.toUpperCase());
  }

  return [];
}

Truthiness Narrowing

Truthiness checks remove values that JavaScript treats as false:

  • false
  • 0
  • 0n
  • ""
  • null
  • undefined
  • NaN

Example:

Code
function UserName({ name }: { name?: string }) {
  if (!name) {
    return <span>Anonymous</span>;
  }

  return <span>{name.toUpperCase()}</span>;
}

This narrows name from string | undefined to string, but it also treats an empty string as missing. That may or may not be correct.

When empty strings or zero are valid values, use explicit checks:

Code
function CharacterCount({ count }: { count: number | null }) {
  if (count === null) {
    return <span>Not calculated</span>;
  }

  return <span>{count}</span>;
}

Truthiness is convenient, but it can hide valid falsy values. Interviewers often look for this nuance.

Equality Narrowing

TypeScript narrows values through equality checks.

Code
function formatStatus(status: "idle" | "loading" | "success" | "error") {
  if (status === "loading") {
    return "Loading...";
  }

  return status;
}

It can also narrow by comparing two variables:

Code
function compare(x: string | number, y: string | boolean) {
  if (x === y) {
    return x.toUpperCase();
  }

  return String(x);
}

In the true branch, the only shared possible type is string, so TypeScript treats both values as strings.

For nullable values, explicit checks are clear:

Code
function getEmail(user: { email?: string | null }) {
  if (user.email == null) {
    return "No email";
  }

  return user.email.toLowerCase();
}

The == null check intentionally removes both null and undefined. Many teams still prefer explicit === null || === undefined checks for readability.

in Operator Narrowing

The in operator checks whether a property exists on an object.

Code
type ApiSuccess = {
  data: string[];
};

type ApiFailure = {
  error: string;
};

type ApiResult = ApiSuccess | ApiFailure;

function renderResult(result: ApiResult) {
  if ("data" in result) {
    return result.data.join(", ");
  }

  return result.error;
}

This is useful when union members have different property names.

Be careful with optional properties:

Code
type Fish = { swim?: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim?.();
  }
}

The property may exist but still be optional, so the value may need another check.

instanceof Narrowing

instanceof checks whether a value is an instance of a class or constructor.

Code
function formatDate(value: Date | string) {
  if (value instanceof Date) {
    return value.toISOString();
  }

  return value;
}

This is useful for built-in classes such as Date, Error, and custom classes.

React example:

Code
function ErrorMessage({ error }: { error: unknown }) {
  if (error instanceof Error) {
    return <p>{error.message}</p>;
  }

  return <p>Something went wrong.</p>;
}

Use instanceof only when runtime values are actually class instances. API data parsed from JSON is plain object data, not class instances.

Discriminated Unions

A discriminated union is a union where each member has a shared literal property that identifies its shape.

Code
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

The status field is the discriminant.

Code
function UsersPanel({ state }: { state: RequestState }) {
  switch (state.status) {
    case "idle":
      return <p>Start searching.</p>;
    case "loading":
      return <p>Loading...</p>;
    case "success":
      return <UserList users={state.data} />;
    case "error":
      return <p>{state.error}</p>;
  }
}

This is one of the most useful TypeScript patterns in React because UI state often has mutually exclusive variants.

Bad state model:

Code
type BadState = {
  loading: boolean;
  data?: User[];
  error?: string;
};

This allows invalid combinations like loading with both data and error. A discriminated union prevents those invalid states.

Reducer Actions and Narrowing

Reducers are a natural fit for discriminated unions.

Code
type CounterAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; value: number };

type CounterState = {
  count: number;
};

function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "set":
      return { count: action.value };
  }
}

Inside the "set" case, TypeScript knows action has a value property. Inside "increment", it knows there is no value property.

This helps prevent dispatching invalid actions:

Code
dispatch({ type: "set", value: 10 });
dispatch({ type: "set" }); // Error.
dispatch({ type: "increment", value: 10 }); // Error.

User-Defined Type Predicates

Sometimes TypeScript cannot infer enough from inline checks. A user-defined type guard returns a type predicate:

Code
type User = {
  id: string;
  name: string;
};

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value
  );
}

Now callers get narrowing:

Code
function renderUnknown(value: unknown) {
  if (isUser(value)) {
    return value.name;
  }

  return "Invalid user";
}

Type predicates are powerful, but TypeScript trusts the function's return type. If isUser lies or checks too little, the rest of the code becomes unsafe.

For untrusted API data, runtime validation libraries or carefully tested validators are often better than casual hand-written guards.

Assertion Functions

An assertion function throws or stops execution when a condition is not met, and tells TypeScript that the value is narrowed after the function returns.

Code
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error("Expected User");
  }
}

Usage:

Code
async function loadUser() {
  const response = await fetch("/api/me");
  const json: unknown = await response.json();

  assertUser(json);

  return json.name;
}

After assertUser(json), TypeScript treats json as User.

Assertion functions are useful at boundaries:

  • API response parsing.
  • Route loader validation.
  • Environment configuration.
  • Context hooks that require a provider.

React context example:

Code
const AuthContext = createContext<AuthContextValue | null>(null);

function useAuth() {
  const value = useContext(AuthContext);

  if (value === null) {
    throw new Error("useAuth must be used inside AuthProvider");
  }

  return value;
}

The explicit runtime check narrows away null for callers.

Narrowing unknown

unknown is safer than any for values whose type is not yet known. You must narrow it before using it.

Code
function getErrorMessage(error: unknown) {
  if (error instanceof Error) {
    return error.message;
  }

  if (typeof error === "string") {
    return error;
  }

  return "Unknown error";
}

In React, unknown is useful for caught errors and external data:

Code
try {
  await saveForm(values);
} catch (error: unknown) {
  setError(getErrorMessage(error));
}

Avoid immediately casting external data:

Code
const user = json as User; // This tells TypeScript to trust you, but it validates nothing.

Prefer validation and narrowing:

Code
if (!isUser(json)) {
  throw new Error("Invalid response");
}

Exhaustiveness Checking with never

Exhaustiveness checking verifies that every union member has been handled.

Code
type ThemeMode = "light" | "dark" | "system";

function getThemeLabel(mode: ThemeMode) {
  switch (mode) {
    case "light":
      return "Light";
    case "dark":
      return "Dark";
    case "system":
      return "System";
    default: {
      const exhaustive: never = mode;
      return exhaustive;
    }
  }
}

If someone later adds "high-contrast" to ThemeMode, TypeScript will fail at the never assignment until the new case is handled.

This is valuable in React render functions:

Code
function RequestView({ state }: { state: RequestState }) {
  switch (state.status) {
    case "idle":
      return <EmptyState />;
    case "loading":
      return <Spinner />;
    case "success":
      return <UserList users={state.data} />;
    case "error":
      return <ErrorBanner message={state.error} />;
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

The compiler helps keep UI rendering synchronized with the state model.

Narrowing and Destructuring

Be careful when destructuring union values too early.

Code
type Props =
  | { kind: "link"; href: string; onClick?: never }
  | { kind: "button"; onClick: () => void; href?: never };

function Action(props: Props) {
  if (props.kind === "link") {
    return <a href={props.href}>Open</a>;
  }

  return <button onClick={props.onClick}>Open</button>;
}

This keeps the discriminant and related fields together on props. Premature destructuring can make code harder to narrow and harder to read, especially in complex unions.

Prefer narrowing the object first, then reading variant-specific fields.

Narrowing Does Not Replace Runtime Validation

TypeScript checks your code at compile time. It does not validate runtime data by itself.

Code
type User = {
  id: string;
  name: string;
};

const user = await response.json() as User;

This cast only changes TypeScript's belief. It does not prove the server returned a valid user.

For trusted internal data, types may be enough. For external data, use runtime checks:

Code
const json: unknown = await response.json();

if (!isUser(json)) {
  throw new Error("Invalid user response");
}

Interviewers often expect this distinction: TypeScript narrows based on checks in your code, but it cannot make untrusted runtime data safe without actual validation logic.

Common Mistakes

Common mistakes include:

  • Using any instead of unknown and losing type safety.
  • Using as assertions to silence the compiler instead of proving the type.
  • Relying on truthiness when 0 or "" are valid values.
  • Forgetting that typeof null is "object".
  • Writing weak type guards that only check one property.
  • Not using discriminated unions for mutually exclusive UI states.
  • Missing exhaustive checks in reducers and render switches.
  • Destructuring complex unions before narrowing.
  • Assuming TypeScript validates API data automatically.

Best Practices

Use these rules of thumb:

  • Model mutually exclusive states with discriminated unions.
  • Prefer explicit null and undefined checks when falsy values are valid.
  • Use unknown at external boundaries and narrow it.
  • Write type predicates only when they perform real runtime checks.
  • Use assertion functions for boundary validation and required context hooks.
  • Use never checks for important union switches.
  • Avoid as unless you have a clear reason and no better proof path.
  • Let control flow do the work instead of over-annotating local variables.

Interview Practice

PreviousStrict equality, reference identity, and immutability implicationsNext Uptsconfig basics, strict mode, and module settings