10.4 Writing Safe Type Guards That Don't Lie
Look, we’ve all been there. You’ve got a value that could be one of a few types, and you’re tired of the compiler giving you the side-eye every time you try to access a property. You reach for a type guard, that trusty if statement that tells TypeScript, “Relax, I’ve checked it. It’s a string.” But here’s the thing: TypeScript trusts you implicitly. It takes your word for it. This is a terrifying amount of power. With great power comes great responsibility, and the responsibility here is to not write a type guard that is a filthy, rotten liar.
A lying type guard is the worst kind of technical debt. It’s a silent assassin that introduces type errors at runtime, precisely the thing we’re using TypeScript to avoid. So let’s build guards that are so robust, so honest, they could run for public office.
The Anatomy of a Truthful Guard
A type guard is, at its heart, a function that returns a type predicate. It’s not magic; it’s a Boolean check that you, the programmer, have vouched for. The syntax is what makes it special:
function isString(value: unknown): value is string {
return typeof value === 'string';
}
const someValue: unknown = "hello";
if (isString(someValue)) {
console.log(someValue.toUpperCase()); // TS now knows it's a string. Safe.
}
The key is the value is string return type. This isn’t a boolean; it’s a promise. A promise that says, “If I return true, you can treat value as a string for the rest of this scope.” Break this promise, and your entire codebase becomes a house of cards.
Going Beyond typeof and instanceof
Primitives are easy. typeof and instanceof do the heavy lifting. The real world, unfortunately, is made of objects. For checking if an object conforms to a specific shape, you need to do a structural check. And no, a simple if (value.prop) is a one-way ticket to undefined is not a function town.
You must check for the existence and the type of the properties that form the core of your type’s identity. Be thorough. Be paranoid.
interface Cat {
meow(): void;
lives: number;
}
interface Dog {
bark(): void;
breed: string;
}
function isCat(pet: Cat | Dog): pet is Cat {
// Check if the key exists AND that its type is what we expect
return (pet as Cat).meow !== undefined &&
typeof (pet as Cat).meow === 'function';
}
const myPet: Cat | Dog = getPetFromAPI();
if (isCat(myPet)) {
myPet.meow(); // Safe
// myPet.bark(); // Error: Property 'bark' does not exist on type 'Cat'
} else {
myPet.bark(); // TS knows it must be a Dog
}
Notice the double cast (pet as Cat)? It’s a bit ugly, but it’s necessary. We’re telling TypeScript, “Let me try to look at this thing as a Cat for a moment so I can check for meow.” We’re not changing the runtime value; we’re just peeking behind the type curtain to perform our inspection.
The Peril of the Optional Property
This is where most developers face-plant. Imagine our Cat interface had an optional property, like declawed?: boolean. Using declawed as part of your type guard would be a catastrophic mistake. An object could be a Cat without it, and a Dog could coincidentally have a declawed property (however unlikely that is for a dog). Always base your guard on a property that is fundamental, unique, and required for the type.
Guarding Against null and undefined
A surprisingly common pitfall is forgetting that your value might be null or undefined. Your type guard is the perfect place to handle this.
function isStringButActuallyForReal(value: unknown): value is string {
return typeof value === 'string' && value !== null;
// Because sadly, `typeof null` is 'object'. Thanks, JavaScript.
}
If your function accepts value: unknown, you must defend against every possible input. This includes null, undefined, NaN, and whatever other nonsense JavaScript decides to throw at you.
Testing Your Guards is Non-Negotiable
You wouldn’t deploy code without tests. Your type guards are critical infrastructure; they deserve the same rigor. Write tests that pass in every possible type that should return true and, more importantly, every possible type that should return false. Test with null. Test with undefined. Test with an object that has some of the right properties but not all. Be malicious. Your future self will thank you when a refactor doesn’t silently break your entire application.
The goal isn’t to make your code bulletproof for the happy path. It’s to make it bomb-proof for the bizarre, edge-casey, “how did this value even get here?” path. A safe type guard is a small piece of code that does one job perfectly: it tells the truth, no matter what.