Union Types & Narrowing
JavaScript developers write defensive checks like if (typeof id === "string")
constantly, and mostly blind — you're guarding against a value that might be a string
or a number, but nothing in the language told you which, or even that both were possible.
TypeScript lets you write that possibility into the type itself, so
the compiler knows exactly when a check is required and holds you to actually doing it.
💡 The Bottom Line
A union type, written string | number, means "this value is one of these
types — could be either." TypeScript won't let you call a string-only or number-only method on
a union value until you narrow it — typically with typeof or
instanceof — down to one specific member of the union. The check itself is
exactly what a JS developer already writes defensively; the difference is the compiler now
enforces that you wrote it.
An id that can be a string or a number
function printId(id) {
console.log(id.toUpperCase());
}
printId("abc123"); // fine
printId(42);
// crashes at runtime:
// id.toUpperCase is not a function
function printId(id: string | number) {
console.log(id.toUpperCase());
// compile error: toUpperCase does
// not exist on type 'number'
}
📋 Key differences
string | numberis a union type — the pipe|means "or."- On a union, TypeScript only allows operations that are valid for every
member of the union —
toUpperCase()isn't valid onnumber, so it's rejected even though it would work fine for thestringbranch. - The JavaScript version only fails when someone actually calls
printId(42); the TypeScript version fails immediately at the definition, regardless of how it's called.
Narrowing with typeof
To use a member-specific method, first prove to the compiler which branch of the union
you're in. A typeof check inside an if does exactly that — inside the
branch, TypeScript treats id as just string, and in the else,
just number.
function printId(id: string | number) {
if (typeof id === "string") {
console.log(id.toUpperCase());
// id is `string` here
} else {
console.log(id.toFixed(2));
// id is `number` here
}
}
function printId(id) {
if (typeof id === "string") {
console.log(id.toUpperCase());
} else {
console.log(id.toFixed(2));
}
}
// same check, but nothing forces
// you to write it, and nothing
// tells you id could be either
Narrowing with instanceof
For unions of classes rather than primitives, instanceof narrows the same way
typeof does for primitives.
class NetworkError {
constructor(public status: number) {}
}
class ValidationError {
constructor(public field: string) {}
}
function handle(err: NetworkError | ValidationError) {
if (err instanceof NetworkError) {
console.log("HTTP", err.status);
// err is NetworkError here
} else {
console.log("bad field:", err.field);
// err is ValidationError here
}
}
function handle(err: NetworkError | ValidationError) {
console.log(err.status);
// compile error: 'status' does not
// exist on type 'ValidationError'
}
⚠️ Gotcha
Narrowing only survives as long as TypeScript can statically trace the control flow.
Extract the typeof check into a separate helper function like
function isString(x: string | number) { return typeof x === "string"; } and call
that instead of checking inline, and the narrowing is lost the moment you use the helper's
return value — TypeScript doesn't automatically know your boolean function's true branch
means "it's a string" unless you write a type predicate
(x is string as the return type). Until later lessons cover that, keep narrowing
checks inline.
🔧 Try It Yourself
Write a function format(value: string | boolean) that returns the value as a
display string — for a string, return it trimmed; for a boolean, return
"yes"/"no". Use a typeof check to narrow, and
deliberately try calling .trim() before the check to see TypeScript reject it.
🙋 There Are No Dumb Questions
Q: If I already write typeof checks defensively in JavaScript, what
does the type system actually add?
A: Enforcement and completeness. In JavaScript, nothing stops you from forgetting the check
on one code path, and nothing tells you a value could ever be two different types in the
first place — you find out from a bug report. In TypeScript, the union type
string | number is documented right in the signature, and the compiler refuses
to let member-specific code compile until the narrowing check is actually present.
✅ Quick Check
Inside if (typeof value === "string") { ... }, where value: string |
number, what type does TypeScript treat value as inside the block?
Next up: time for the recap — but first, a lesson on the traps that catch even developers
who've internalized everything so far: any, strict mode, and untyped libraries.