Narrowing, unions, and why not enums
- Narrow a union with
typeof,in,instanceofand equality checks, including thetypeof null === "object"trap - Write a type guard for
unknowndata, and explain that TypeScript trusts a predicate without checking it - Model states as a discriminated union, with a
nevercheck that turns a newly added case into a compile error - Choose a union or an
as constobject (checked withsatisfies) over anenum, citing enums' runtime objects,isolatedModules, TS 5.8erasableSyntaxOnlyand Node type stripping
Read
Narrowing — a runtime check the compiler follows
FlagA 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:
| Check | Narrows | Example |
|---|---|---|
typeof x === "string" | primitives ("string", "number", "boolean", "undefined", "function", "object", …) | string | number → string |
x === null, x === "admin" | to the exact value | Role | null → null |
if (x) | drops null/undefined (at runtime it also skips 0 and "") | User | undefined → User |
"swim" in x | object types by a property they have | Fish | Bird → Fish |
x instanceof Error | class instances | unknown → 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.
Type guards for data you didn't create
FlagJSON.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 fromunknownto "an object I can look inside". Skip thenullcheck and"id" in xthrows aTypeErrorat runtime for anullbody (inonly works on objects)."id" in x && typeof x.id === "string"—inproves the key exists;typeofproves what's in it. Since TS 4.9,"id" in xalso lets you readx.id(asunknown) on an object that wasn't declared to have it.x is Order— the predicate. WhenisOrder(body)returnstrue, TypeScript narrowsbodytoOrderin that branch, and in theelsebranch 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.
Enums vs unions — what an enum costs at runtime and in tooling
FlagAlmost 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 enumpromises no runtime object: withoutisolatedModules,tscinlines each member (Level.Lowbecomes1 /* Level.Low */) and deletes the declaration. UnderisolatedModules— the setting for code compiled one file at a time — TypeScript forcespreserveConstEnumson, so the object is emitted after all; and reading aconst enumdeclared in a.d.tsfile (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
erasableSyntaxOnlyerrors 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>valueassertions (writevalue 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
.tsfiles 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+ witherasableSyntaxOnlyandverbatimModuleSyntaxso the compiler reports this before Node does. (The opt-in--experimental-transform-typesflag 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?
Under strict, catch (err) gives err: unknown. Which check lets you read err.message safely?
What does TypeScript verify about function isUser(x: unknown): x is User { return true; }?
Which type makes "loading and failed at the same time" impossible to represent?
FlagYou add { kind: "refunded" } to the Payment union. What does const unhandled: never = payment; in a switch's default branch do?
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?
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?
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?
Fill in the blank
Narrow input: string | number to a string before calling a string method.
if (typeof input === ) return input.trim();
Write a type guard for unknown input: rule out null, then prove id exists and is a string.
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.
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.
Flagconst 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.
const LABELS = { open: "Open", shipped: "Shipped" } Record<Status, string>;
Predict the output
enum Direction { Up, Down }
console.log(Object.keys(Direction));const values = [null, [], {}, "x"];
console.log(values.map((v) => typeof v));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);Challenge
Narrow an untrusted webhook body into a typed event
FlagA 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 fornull.- A type guard (
x is …) is a promise. TypeScript checks that the function returns a boolean, not that the body proves the claim. as AppEventturns "a kind I've never seen" into a valid event.- A
defaultthat 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,paymentandcancelbodies parse withok: trueand every field intact parseEvent(null)returnsok: falseinstead of throwing- A payment whose
amountCentsis the string"1200"is rejected - A body with an unknown
kindis rejected instead of being passed through feedLinedescribes a cancel as"u1 cancelled: too expensive", or"u2 cancelled"without a reasonfeedLinethrows for akindthat isn't inAppEvent, instead of returning a fallback line
Further reading
- TypeScript Handbook: Narrowing
- TypeScript Handbook: Enums
- TSConfig reference: isolatedModules
- TypeScript 5.8 release notes: --erasableSyntaxOnly
- TypeScript 4.9 release notes: satisfies, and in narrowing
- TypeScript 5.0 release notes: all enums are union enums
- Node.js docs: Modules — TypeScript (type stripping)