Advanced Narrowing: Type Guards, instanceof, as & never
Go beyond typeof/in: write your own type guards, narrow by class with instanceof, use as carefully, and prove you handled every case with never.
What you will learn
- Write a custom type guard with the "x is Type" return
- Narrow class instances with instanceof
- Force exhaustive switches with never, and use as safely
A quick recap, then four new tools
Earlier you narrowed unions with typeof (for primitives) and in (for object properties). Real code needs four more tools when those are not enough: custom type guards, instanceof, the as assertion, and never-based exhaustiveness checks. Let us take them one at a time.
Custom type guards: a function that returns "x is Type"
Sometimes the check to decide a type is complicated — too much for a one-line typeof. You can move it into a function whose return type is the special form value is Type. This is a type guard: when it returns true, TypeScript narrows the value for you.
interface Cat { meow: () => string; }
interface Dog { bark: () => string; }
// The return type "pet is Cat" is the magic part
function isCat(pet: Cat | Dog): pet is Cat {
return 'meow' in pet;
}
function speak(pet: Cat | Dog): string {
if (isCat(pet)) {
return pet.meow(); // narrowed to Cat by our guard
}
return pet.bark(); // narrowed to Dog
}
console.log(speak({ meow: () => 'Meow!' }));
console.log(speak({ bark: () => 'Woof!' }));Note: Output:
Meow!
Woof!
The return type pet is Cat tells TypeScript: "if this function returns true, treat the argument as a Cat". Now if (isCat(pet)) narrows just like a built-in guard — and you can reuse the guard anywhere.
instanceof: narrowing by class
When your union is of class instances, the cleanest guard is instanceof. It asks "was this object built from this class?" and narrows accordingly.
class ApiError extends Error {
status: number;
constructor(status: number) {
super('API failed');
this.status = status;
}
}
function report(err: Error | ApiError): string {
if (err instanceof ApiError) {
return 'API error, code ' + err.status; // narrowed to ApiError
}
return 'General error: ' + err.message; // plain Error
}
console.log(report(new ApiError(404)));
console.log(report(new Error('Oops')));Note: Output:
API error, code 404
General error: Oops
Inside if (err instanceof ApiError), TypeScript knows err has the status field. Outside it, err is just an Error, so only .message is available.
The as assertion: "trust me, I know the type"
A type assertion with as tells TypeScript to treat a value as a specific type, overriding what it inferred. Use it sparingly — it is *you* promising the type, with no runtime check, so a wrong promise causes a real bug.
// A value typed unknown — e.g. parsed JSON
const raw: unknown = { name: 'Asha', age: 28 };
// We assert its shape so we can use it
const user = raw as { name: string; age: number };
console.log(user.name.toUpperCase());Note: Output:
ASHA
raw as { name: string; age: number } tells TypeScript to trust that shape. It compiled and ran because the data really matched. If the data had been different, TypeScript would NOT have caught it — that is the risk of as.
Watch out: Prefer a real check (a custom type guard) over as whenever you can. as silences the compiler but does no runtime checking — assert the wrong type and the bug slips straight through. Never use as just to make a red squiggle disappear.
never & exhaustiveness: prove you handled every case
When you switch over a discriminated union, you want a guarantee that you covered every variant — even if someone adds a new one later. The trick uses never, a special type meaning "a value that can never happen". In the default branch, assign the value to a never variable: if any case is unhandled, TypeScript errors right there.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
// If every case is handled, shape is 'never' here
const _exhaustive: never = shape;
return _exhaustive;
}
}
console.log(area({ kind: 'square', side: 4 }));Note: Output:
16
Both cases are handled, so in default the only possible type is never, and the assignment compiles. The moment you add a new shape (say triangle) without a case for it, shape is no longer never there and TypeScript errors — reminding you to handle it.
The narrowing toolkit, complete
| Tool | Use it to narrow |
|---|---|
typeof x === 'string' | Primitive types (string, number, boolean) |
'prop' in obj | Which object shape you have |
x instanceof Class | Which class an object came from |
function f(x): x is T | A reusable custom rule of any complexity |
x as T | Force a type when you know more than the compiler (careful!) |
never in default | Guarantee a switch handled every variant |
Tip: Add the never exhaustiveness check to every switch over a discriminated union. It turns "I forgot a case" from a silent runtime bug into a compile-time error — one of TypeScript’s most loved safety patterns.
Q. What does a function with the return type pet is Cat do?
✍️ Practice
- Write a custom guard
isString(x: unknown): x is stringand use it to safely uppercase a value. - Add a third variant to the Shape union above and confirm the
neverdefault forces you to add a case.
🏠 Homework
- Create an
Animalclass hierarchy (Fish, Bird) and a function that usesinstanceofto return how each moves. Add anever-based exhaustiveness check and describe what error appears if you remove one case.