Skip to content

Literal Types and Narrowing

intermediate11 min read

Types That Represent Exact Values

Most type systems stop at "this is a string" or "this is a number." TypeScript goes further — it can track the exact value. The string "hello" isn't just string, it's the literal type "hello". The number 42 isn't just number, it's the literal type 42. This precision is what makes discriminated unions, exhaustive checks, and type-level string manipulation possible.

Mental Model

Think of regular types as categories — "string" is the category of all text, "number" is the category of all numbers. Literal types are specific items within those categories — "hello" is one specific string, 42 is one specific number. TypeScript lets you zoom in from the category level to the item level, and narrowing is the act of zooming in during runtime checks.

Literal Types and const vs let

// const infers literal types — the value can never change
const greeting = "hello";     // type: "hello" (not string)
const count = 42;             // type: 42 (not number)
const active = true;          // type: true (not boolean)

// let widens to base types — the value might change
let greeting = "hello";       // type: string
let count = 42;               // type: number
let active = true;            // type: boolean

This distinction is critical for function signatures:

function setDirection(dir: "north" | "south" | "east" | "west") {}

const direction = "north";
setDirection(direction); // OK — "north" is assignable to the union

let direction2 = "north";
setDirection(direction2); // ERROR — string is not assignable to "north" | "south" | "east" | "west"

as const — The Const Assertion

as const tells TypeScript to infer the narrowest possible type for an entire expression:

// Without as const
const config = {
  host: "localhost",  // string
  port: 3000,         // number
  modes: ["dev", "prod"], // string[]
};

// With as const
const config = {
  host: "localhost",  // "localhost"
  port: 3000,         // 3000
  modes: ["dev", "prod"], // readonly ["dev", "prod"]
} as const;

// Type is:
// {
//   readonly host: "localhost";
//   readonly port: 3000;
//   readonly modes: readonly ["dev", "prod"];
// }
Common Trap

as const makes everything readonly AND narrows to literal types. This means you can't push to arrays, reassign properties, or mutate anything. If you need to mutate the object later, as const is wrong — use explicit literal type annotations instead:

// If you need host to be a literal but the object mutable:
const config: { host: "localhost" | "production"; port: number } = {
  host: "localhost",
  port: 3000,
};
config.port = 8080; // OK — port is still mutable

Narrowing — How TypeScript Zooms In

Narrowing is TypeScript's ability to refine a type within a control flow branch. There are several narrowing mechanisms:

typeof Narrowing

function process(value: string | number | boolean) {
  if (typeof value === "string") {
    // value: string
    return value.toUpperCase();
  }
  if (typeof value === "number") {
    // value: number
    return value.toFixed(2);
  }
  // value: boolean (only option left)
  return value ? "yes" : "no";
}

instanceof Narrowing

function handleError(err: Error | TypeError | RangeError) {
  if (err instanceof RangeError) {
    // err: RangeError
    console.log("Range:", err.message);
  } else if (err instanceof TypeError) {
    // err: TypeError
    console.log("Type:", err.message);
  } else {
    // err: Error
    console.log("Generic:", err.message);
  }
}

in Operator Narrowing

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

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

Truthiness Narrowing

function greet(name: string | null | undefined) {
  if (name) {
    // name: string (null and undefined are falsy, removed)
    console.log(`Hello, ${name}`);
  } else {
    // name: string | null | undefined (could be "", null, or undefined)
    console.log("Hello, stranger");
  }
}
Common Trap

Truthiness narrowing removes null, undefined, 0, "", false, and NaN. This means it also removes valid values like empty strings and zero. If empty string or zero are valid inputs, use explicit null checks instead:

function process(count: number | null) {
  if (count) {
    // WRONG — removes 0, which might be valid
  }
  if (count !== null) {
    // CORRECT — only removes null, keeps 0
    console.log(count.toFixed(2)); // count: number (including 0)
  }
}

Equality Narrowing

function compare(a: string | number, b: string | boolean) {
  if (a === b) {
    // a and b must be the same type — only string overlaps
    // a: string, b: string
    console.log(a.toUpperCase(), b.toUpperCase());
  }
}

Control Flow Analysis — How It Actually Works

TypeScript tracks the type of every variable at every point in the code. Each branch refines the type:

function example(x: string | number | null) {
  // x: string | number | null

  if (x === null) {
    // x: null
    return;
  }
  // x: string | number (null eliminated by early return)

  if (typeof x === "string") {
    // x: string
    return x.toUpperCase();
  }
  // x: number (string eliminated by the if)

  return x.toFixed(2);
}
Execution Trace
Entry:
x: string | number | null
All three types possible
x === null:
True branch: x is null → return
After this check, null is eliminated from subsequent code
Post-null:
x: string | number
Early return means null can't reach here
typeof:
True branch: x is string
typeof narrows to string in the if body
Post-typeof:
x: number
Both null and string eliminated — only number remains

Production Scenario: Event Handler with Discriminated Events

type AppEvent =
  | { type: "USER_LOGIN"; userId: string; timestamp: number }
  | { type: "USER_LOGOUT"; userId: string; sessionDuration: number }
  | { type: "PAGE_VIEW"; path: string; referrer?: string }
  | { type: "PURCHASE"; productId: string; amount: number; currency: string };

function trackEvent(event: AppEvent) {
  // Common property — always available
  console.log(`Event: ${event.type}`);

  switch (event.type) {
    case "USER_LOGIN":
      analytics.identify(event.userId);
      break;
    case "USER_LOGOUT":
      analytics.track("session", { duration: event.sessionDuration });
      break;
    case "PAGE_VIEW":
      analytics.page(event.path, { referrer: event.referrer });
      break;
    case "PURCHASE":
      analytics.revenue(event.amount, event.currency, event.productId);
      break;
  }
}

// Type-safe event creators using as const
const events = {
  login: (userId: string) => ({
    type: "USER_LOGIN" as const,
    userId,
    timestamp: Date.now(),
  }),
  logout: (userId: string, sessionDuration: number) => ({
    type: "USER_LOGOUT" as const,
    userId,
    sessionDuration,
  }),
} satisfies Record<string, (...args: any[]) => AppEvent>;

trackEvent(events.login("user_123")); // Type-safe
Common Mistakes
  • Wrong: Using let when the value never changes — losing literal type inference Right: Use const for values that don't change — get literal types for free

  • Wrong: Trusting truthiness narrowing for values where 0 or '' are valid Right: Use explicit null/undefined checks: value !== null && value !== undefined

  • Wrong: Using as const on objects you need to mutate later Right: Use specific literal annotations on just the properties that need narrowing

  • Wrong: Casting with 'as' instead of narrowing with control flow Right: Use typeof, instanceof, in, or discriminant checks to narrow naturally

Quiz
What type does TypeScript infer for: const x = [1, 2, 3] as const?
Quiz
After: if (typeof x === 'object') — what types are NOT eliminated from x: string | number | object | null?
Quiz
Why does let x = 'north' fail when passed to a function expecting 'north' | 'south'?

Type-Safe Route Params

Key Rules
  1. 1const infers literal types, let widens to base types — this affects whether values work with literal unions
  2. 2as const makes an entire expression readonly with literal types — use it for config objects, route maps, and enum-like constants
  3. 3Narrowing mechanisms: typeof, instanceof, in, equality checks, truthiness, and discriminant property checks
  4. 4Truthiness narrowing removes all falsy values (0, '', null, undefined, false, NaN) — use explicit checks when 0 or '' are valid
  5. 5TypeScript's control flow analysis tracks narrowed types through every branch, including early returns and assignments