Teaching TypeScript to Read Your Mind: Type Narrowing and Type Guards
A breakdown of how typeof, instanceof, in, and custom type guards let TypeScript shrink a union type inside a conditional — paired with discriminated unions to write safe code without sprinkling type assertions everywhere.
You already checked the type. Why is TypeScript still complaining?
You've probably run into this: you define a union type, write an if to rule out the branch you don't want, and the editor still lights up red.
function printLength(value: string | number) {
console.log(value.length) // Error: number doesn't have a length property
}The problem here is obvious enough — value might be a number, and number has no length property, so TypeScript isn't going to budge. But what if you add a check first?
function printLength(value: string | number) {
if (typeof value === 'string') {
console.log(value.length) // OK: value is inferred as string here
}
}See what happened? Same value.length line, but once you add the typeof check, the error disappears. TypeScript isn't being fussy — it genuinely understood the logic of this code and, inside the if block, collapsed value's type from string | number down to plain string. This is called type narrowing, and the condition that makes it possible is called a type guard.
What narrowing actually is: the compiler following your branches
Think of narrowing as a reasoning engine built into TypeScript. It walks the control flow of your code, and at every conditional, it re-evaluates "given that I'm inside this branch, which types are actually still possible for this variable?"
This has nothing to do with runtime behavior — TypeScript isn't reading the variable's actual value. It's pure static analysis. TypeScript reads typeof value === 'string' and understands it to mean "we only get inside this block if value really is a string," so within that block, it filters number out of string | number.
Once you get that mechanism, the next question is: which conditionals does TypeScript actually recognize as narrowing triggers? Those are type guards, and there are several flavors, each suited to a different situation.
typeof: telling primitives apart
typeof is the most basic type guard, built for distinguishing primitives like string, number, boolean, undefined, and function.
function format(value: string | number | boolean) {
if (typeof value === 'string') {
return value.toUpperCase() // value: string
}
if (typeof value === 'number') {
return value.toFixed(2) // value: number
}
return value ? 'yes' : 'no' // value: boolean (inferred automatically once the first two are ruled out)
}instanceof: telling class instances apart
typeof doesn't help once you're dealing with custom classes — that's where instanceof takes over.
class ApiError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
super(message)
this.statusCode = statusCode
}
}
function handleError(error: Error | ApiError) {
if (error instanceof ApiError) {
console.log(error.statusCode) // error: ApiError
} else {
console.log(error.message) // error: Error
}
}in: checking whether an object has a property
When every member of a union is a plain object and they're distinguished by different property names, the in operator is the most direct tool for the job.
type Circle = { kind: 'circle'; radius: number }
type Square = { kind: 'square'; side: number }
function area(shape: Circle | Square) {
if ('radius' in shape) {
return Math.PI * shape.radius ** 2 // shape: Circle
}
return shape.side ** 2 // shape: Square
}All
inreally does is check whether a key exists on an object. TypeScript then matches that result against each member's property definitions in the union to figure out which one you're dealing with.
Discriminated unions: a design pattern that makes narrowing precise
That area example above is hiding an even more valuable pattern: the discriminated union. Give every member of a union the same property — usually named kind, type, or tag — and make its value a unique literal type.
type Circle = { kind: 'circle'; radius: number }
type Square = { kind: 'square'; side: number }
type Triangle = { kind: 'triangle'; base: number; height: number }
type Shape = Circle | Square | Triangle
function area(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2 // shape: Circle
case 'square':
return shape.side ** 2 // shape: Square
case 'triangle':
return (shape.base * shape.height) / 2 // shape: Triangle
}
}By checking a single property, shape.kind, TypeScript can nail down the full type in each case branch — no need to guess your way through properties one at a time the way in requires. This pattern shines for state machines and API responses that come in multiple shapes, and it's the go-to design whenever a type varies based on one field.
Discriminated unions come with a bonus: pair them with switch and the never type, and TypeScript will catch an unhandled branch for you at compile time.
function assertNever(value: never): never {
throw new Error(`Unhandled kind: ${JSON.stringify(value)}`)
}
function area(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.side ** 2
default:
return assertNever(shape) // if Triangle gets added later and this case is forgotten, this line fails to compile
}
}If a new member gets added to Shape later and nobody adds the matching case, shape in the default branch is no longer never, and assertNever fails to compile — a nudge that a branch got missed.
Custom type guards: wrapping complex logic in a function
The guards above are all built into TypeScript, but when your check gets more involved — or you want to reuse the same logic in multiple places — you can write your own function that returns a type predicate.
type Cat = { meow: () => void }
type Dog = { bark: () => void }
function isCat(animal: Cat | Dog): animal is Cat {
return 'meow' in animal
}
function speak(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow() // animal: Cat
} else {
animal.bark() // animal: Dog
}
}The key part is the return type, animal is Cat, instead of a plain boolean. That's you telling TypeScript, in effect: "whenever this function returns true, treat the argument at the call site as a Cat." TypeScript doesn't verify that the logic inside the function actually holds up — that guarantee is entirely on you. Get it wrong and TypeScript won't catch it, so keep custom type guards simple and make sure the check genuinely lines up with the type definition.
The real payoff of a custom type guard is encapsulation. Once a check takes more than one line, or the same logic needs to run in several places, a named function reads far more clearly than pasting the same if block everywhere — and it's much easier to maintain in one spot.
Wrapping up
Narrowing is the static reasoning TypeScript does as it walks your code's control flow — any conditional it recognizes as a type guard collapses the type inside that branch. typeof distinguishes primitives, instanceof distinguishes class instances, in checks for object properties, and discriminated unions are the pattern that makes narrowing across multi-type unions precise and maintainable. When your logic gets too complex for any built-in guard to cover, write a custom type guard function that returns an is type predicate. Once you've got these tools down, the fix for a type error isn't reaching for an as assertion to shut the compiler up — it's figuring out which type guard actually fits, so TypeScript genuinely understands what your code means to do.