9.2 typeof Narrowing: Checking Primitive Types
Right, let’s talk about typeof narrowing. It’s the first tool you’ll reach for and, honestly, the one you’ll probably use the most. The concept is gloriously simple: you check the type of a value at runtime, and TypeScript, being the clever little language service it is, uses that information to narrow the type of that variable within the subsequent code block. It’s like a bouncer for your code—it checks IDs and only lets the right types into the club.
Here’s the most basic, almost insultingly straightforward example:
function printLength(thing: string | number) {
// Right here, 'thing' is a menace. It could be either type.
if (typeof thing === "string") {
// In here, TypeScript *knows* it's a string. The bouncer checked the ID.
console.log(thing.length); // Perfectly safe, no errors.
} else {
// And in here, by the power of elimination, it *must* be a number.
console.log(thing.toFixed(2)); // Also safe.
}
}
See? No magic. Just a conditional check that gives the compiler a logical guarantee about the type. It’s the programming equivalent of saying, “Well, if it isn’t a fish, it must be the bicycle.”
The Usual Suspects: What typeof Can Actually Check
This is where the first “questionable choice” rears its head. You’d think typeof would return the lowercase names of the types you use every day, right? string, number, boolean, object… Sure. But then you get to null.
console.log(typeof null); // "object"
Yes, you read that right. This is a infamous quirk that’s been in JavaScript since, like, the dawn of time. It’s not a TypeScript bug; it’s a JavaScript reality. TypeScript has to model this behavior, which means you cannot use typeof to distinguish between a general object and null. If you try to narrow a string | null with typeof thing === "object", you’ll have a bad time. For null, you must use a direct equality check (thing === null). We’ll get to that in the next section.
Here’s the complete list of what you can reliably use with typeof narrowing:
typeof "hello" === "string";
typeof 42 === "number";
typeof true === "boolean";
typeof undefined === "undefined";
typeof Symbol("foo") === "symbol";
typeof BigInt(9007199254740991) === "bigint"; // The new kid on the block
// And the two troublemakers:
typeof function () {} === "function";
typeof [] === "object"; // Wait, an array is an 'object'? Yep.
typeof {} === "object";
Notice what’s missing? array. There is no "array" string returned. If you need to narrow an array, typeof won’t cut it. You’ll need Array.isArray(), which is a function TypeScript also understands for narrowing.
A Classic Pitfall: The Dreaded Undefined Check
This one bites everyone eventually. Let’s say you have a function that takes an optional parameter.
function formatValue(value?: string) {
if (typeof value === "string") {
return value.trim(); // All good here.
}
// You might think: "If it's not a string, it must be undefined."
// But is 'value' really 'undefined' in this branch?
console.log(value); // Type is `undefined`... for now.
}
Seems fine. But watch what happens if someone passes null. The type string? is actually a union type: string | undefined. It does not include null. So if you, for some reason, pass null to this function, the typeof value === "string" check fails, and you end up in the else block. But what is the type of value in that block?
formatValue(null); // This is allowed because `null` is not excluded by `string | undefined`
// Inside the else block, `value` is now of type `undefined`... but its value is actually `null`!
This is a runtime error waiting to happen if you assume value is truly undefined. The compiler narrowed based on the known union members (string and undefined), but the value null is still a possiblity that wasn’t accounted for in the original type. The lesson: know what’s actually in your union types. If null is a possibility, you must check for it explicitly. Your types are a contract, but runtime data can sometimes break that contract.
Why This Is So Powerful: Exhaustiveness and Safety
The real beauty of typeof narrowing shines when combined with a switch statement or a series of if/else if blocks. You can create a structure that is provably exhaustive. Let’s say you’re dealing with a union of primitives.
type SupportedType = string | number | boolean | undefined;
function handleType(input: SupportedType) {
switch (typeof input) {
case "string":
return input.length;
case "number":
return input.toFixed();
case "boolean":
return !input;
case "undefined":
return "You gave me nothing!";
default:
// This is the killer feature. TypeScript knows the above cases
// are exhaustive. The type of 'input' in the 'default' branch
// is 'never' because there's no possible type left.
const _exhaustiveCheck: never = input;
return _exhaustiveCheck;
}
}
That default branch is your safety net. If you ever expand the SupportedType union to include, say, bigint, TypeScript will immediately throw an error on the const _exhaustiveCheck line because bigint cannot be assigned to never. It forces you to handle every case you said you would. This is how you write rock-solid, maintainable code. It’s not just clever—it’s a guardrail against your own future forgetfulness.