JavaScript & TypeScript Fundamentals
JavaScript & TypeScript Fundamentals

Narrowing, unions, and why not enums

Full · ~35 min
After this unit you can
  • Narrow a union with typeof, in, instanceof and equality checks, including the typeof null === "object" trap
  • Write a type guard for unknown data, and explain that TypeScript trusts a predicate without checking it
  • Model states as a discriminated union, with a never check that turns a newly added case into a compile error
  • Choose a union or an as const object (checked with satisfies) over an enum, citing enums' runtime objects, isolatedModules, TS 5.8 erasableSyntaxOnly and Node type stripping

Read

Simple
1 / 2

Narrowing — a runtime check the compiler follows

Flag

A union type says a value could be one of several things: string | number, User | null. You can't use it as either until you check which one you have. Narrowing is TypeScript following your ordinary JavaScript checks through ifs, switches and early returns, and shrinking the type in each branch:

function label(id: string | number) {
  if (typeof id === "string") {
    return id.toUpperCase(); // here id is string
  }
  return id.toFixed(0);      // here id is number — the string case returned
}

No new syntax: the check is plain JavaScript that runs at runtime, and TypeScript reads it. Five checks cover almost everything:

CheckNarrowsExample
typeof x === "string"primitives ("string", "number", "boolean", "undefined", "function", "object", …)string | number → string
x === null, x === "admin"to the exact valueRole | null → null
if (x)drops null/undefined (at runtime it also skips 0 and "")User | undefined → User
"swim" in xobject types by a property they haveFish | Bird → Fish
x instanceof Errorclass instancesunknown → Error

One trap is built into JavaScript itself: typeof null is "object". So typeof x === "object" leaves null in the type — TypeScript shows object | null in that branch, and makes you rule out null before you touch a property:

if (typeof value === "object" && value !== null) {
  Object.keys(value); // value is object here
}

Narrowing is only as good as the check. Writing x as User is not narrowing — it skips the check and tells the compiler to believe you.

Medium
1 / 2

Type guards for data you didn't create

Flag

JSON.parse, response.json(), localStorage, a catch (err) and a postMessage event all give you data whose shape nobody has checked. Type it as unknown, not any: unknown forces a check before use, any switches checking off.

To narrow unknown to your own type, write a type guard — a function whose return type is a type predicate, x is Order:

type Order = { id: string; totalCents: number };

function isOrder(x: unknown): x is Order {
  return (
    typeof x === "object" &&
    x !== null &&                       // typeof null is "object"
    "id" in x && typeof x.id === "string" &&
    "totalCents" in x && typeof x.totalCents === "number"
  );
}

const body: unknown = await response.json();
if (!isOrder(body)) throw new Error("unexpected response shape");
body.totalCents; // Order from here on

Line by line:

  • typeof x === "object" && x !== null — the only way to get from unknown to "an object I can look inside". Skip the null check and "id" in x throws a TypeError at runtime for a null body (in only works on objects).
  • "id" in x && typeof x.id === "string" — in proves the key exists; typeof proves what's in it. Since TS 4.9, "id" in x also lets you read x.id (as unknown) on an object that wasn't declared to have it.
  • x is Order — the predicate. When isOrder(body) returns true, TypeScript narrows body to Order in that branch, and in the else branch knows it isn't one.

TypeScript trusts the predicate

The compiler checks that a guard returns a boolean. It does not check that the body proves what the predicate claims:

function isOrder(x: unknown): x is Order {
  return typeof x === "object" && x !== null && "id" in x; // never checks totalCents
}

This compiles, and every caller now treats { id: "1", totalCents: "1200" } as an Order — order.totalCents + 100 produces the string "1200100". A type guard is a promise you make to the compiler, like as, so test it like code. For anything bigger than a few fields, a schema library that derives the type from the check (zod and its peers) keeps the two from drifting.

Complex
1 / 2

Enums vs unions — what an enum costs at runtime and in tooling

Flag

Almost everything in TypeScript is erased: types, interfaces, as, !. enum is the exception — it compiles to a real JavaScript object. The handbook says so plainly: "Enums are real objects that exist at runtime."

enum Direction { Up, Down }
// emits roughly:
// var Direction; (function (Direction) {
//   Direction[Direction["Up"] = 0] = "Up";
//   Direction[Direction["Down"] = 1] = "Down";
// })(Direction || (Direction = {}));

Object.keys(Direction); // ["0", "1", "Up", "Down"]

A numeric enum gets reverse mappings (value → name), so iterating it yields twice as many keys as you declared, half of them numeric strings. String enums (Admin = "admin") get no reverse mapping, but they're nominal: a plain "admin" from an API isn't assignable to Role.Admin without a cast.

Where enums collide with modern tooling

  • const enum promises no runtime object: without isolatedModules, tsc inlines each member (Level.Low becomes 1 /* Level.Low */) and deletes the declaration. Under isolatedModules — the setting for code compiled one file at a time — TypeScript forces preserveConstEnums on, so the object is emitted after all; and reading a const enum declared in a .d.ts file (an ambient one, as libraries publish them) is an error: Cannot access ambient const enums when 'isolatedModules' is enabled. The handbook's advice is to avoid publishing const enums, or avoid them entirely.
  • TS 5.8's erasableSyntaxOnly errors on the constructs that aren't just erasable type syntax: enum (const or not), namespaces with runtime code, constructor parameter properties, import =/export =, and old-style <Type>value assertions (write value as Type).
  • Node's built-in type stripping — on by default since Node 23.6 and 22.18, stable since 25.2 and 24.12 — runs .ts files by deleting the types, with no type checking. It can't delete an enum, so it refuses the file: ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript enum is not supported in strip-only mode. Node's docs recommend TS 5.8+ with erasableSyntaxOnly and verbatimModuleSyntax so the compiler reports this before Node does. (The opt-in --experimental-transform-types flag that compiled enums was removed in Node 26.)

The alternatives the handbook suggests

A union of string literals covers most uses with zero runtime cost:

type Role = "admin" | "editor" | "viewer";

When you also need the values at runtime — to loop over them, or validate input — declare them once as an as const object (or array) and derive the type:

const Role = { Admin: "admin", Editor: "editor", Viewer: "viewer" } as const;
type Role = (typeof Role)[keyof typeof Role]; // "admin" | "editor" | "viewer"

Role.Admin;          // reads like an enum member
Object.values(Role); // ["admin", "editor", "viewer"], no reverse-mapping noise

Same name for the value and the type is legal (they live in different namespaces) and reads like an enum at the call site. A plain "admin" from an API is assignable to this Role.

Quiz

if (typeof value === "object") return Object.keys(value); — written in plain JavaScript, which input still crashes it?

Flag

Under strict, catch (err) gives err: unknown. Which check lets you read err.message safely?

Flag

What does TypeScript verify about function isUser(x: unknown): x is User { return true; }?

Flag

Which type makes "loading and failed at the same time" impossible to represent?

Flag

You add { kind: "refunded" } to the Payment union. What does const unhandled: never = payment; in a switch's default branch do?

Flag

Your team starts running .ts files directly with Node's built-in type stripping and turns on TS 5.8's erasableSyntaxOnly. Which declaration has to change?

Flag

A library's .d.ts declares export declare const enum Level { Low = 1, High = 2 }. In your app, with isolatedModules on, you write Level.Low. What happens?

Flag

const routes: Record<string, string | ((id: string) => string)> = { home: "/", user: (id: string) => "/u/" + id }; — then routes.user("7") fails to compile. What fixes it while still checking the object?

Flag

Fill in the blank

Narrow input: string | number to a string before calling a string method.

Flag
if (typeof input === ) return input.trim();

Write a type guard for unknown input: rule out null, then prove id exists and is a string.

Flag
function isOrder(x: unknown): x  Order {
  return typeof x === "object" && x  null && "id" in x && typeof x.id === "string";
}

Make the default branch a compile error whenever a Shape member isn't handled — and a loud failure at runtime.

Flag
default: {
  const unhandled:  = shape;
   new Error(`Unhandled shape: ${JSON.stringify(unhandled)}`);
}

Declare the roles once, as a readonly tuple of literals, and derive the union type from it.

Flag
const ROLES = ["admin", "editor", "viewer"] as ;
type Role = (typeof ROLES)[];

Check that the label map covers every Status key (here "open" | "shipped") while the object keeps its own inferred type, instead of taking a declared one.

Flag
const LABELS = { open: "Open", shipped: "Shipped" }  Record<Status, string>;

Predict the output

enum Direction { Up, Down }
console.log(Object.keys(Direction));
Flag
const values = [null, [], {}, "x"];
console.log(values.map((v) => typeof v));
Flag
type Money = { amount: number; currency: string };
function isMoney(x: unknown): x is Money {
  return typeof x === "object" && x !== null && "amount" in x;
}
const body: unknown = JSON.parse('{"amount": "12", "currency": "EUR"}');
if (isMoney(body)) console.log(body.amount + 1);
Flag

Challenge

Narrow an untrusted webhook body into a typed event

Flag

A payment provider posts JSON to your webhook. parseEvent takes the parsed body — typed unknown, because nobody has checked it yet — and either returns a typed AppEvent or rejects it. feedLine turns an event into a line for the activity feed. The event types are in types.ts.

Everything compiles under strict. That's the point: each bug is a place where a check was missing at runtime, or where the code told the compiler to stop checking:

  • typeof x === "object" is true for null.
  • A type guard (x is …) is a promise. TypeScript checks that the function returns a boolean, not that the body proves the claim.
  • as AppEvent turns "a kind I've never seen" into a valid event.
  • A default that returns a fallback string compiles forever, including after someone adds a member to the union. Make the compiler list the cases you forgot.

The preview runs six real bodies through the pipeline.

Your task

Edit events.ts so the tests pass:

  • Valid signup, payment and cancel bodies parse with ok: true and every field intact
  • parseEvent(null) returns ok: false instead of throwing
  • A payment whose amountCents is the string "1200" is rejected
  • A body with an unknown kind is rejected instead of being passed through
  • feedLine describes a cancel as "u1 cancelled: too expensive", or "u2 cancelled" without a reason
  • feedLine throws for a kind that isn't in AppEvent, instead of returning a fallback line

Loading the in-browser editor…

Further reading