TypeScript Narrowing: in, typeof, instanceof, and User-Defined Type Predicates
Type narrowing is the process TypeScript uses to refine a value’s apparent type based on control-flow and runtime checks. This article explains the four primary narrowing tools—typeof, instanceof, in, and user-defined predicates—along with recommended patterns and common pitfalls.
function getCurrencyInPounds(amount: number | string) {
amount.toFixed(2);
}
What happens in the above example? Well if we tried to enter that line of code in VS Code we’d see the following error:
Property 'toFixed' does not exist on type 'string | number'.
Property 'toFixed' does not exist on type 'string'.
Infact, if we look at the autocomplete in TypeScript we only have 3 options when trying to call a method on amount:
toLocaleString()toString()valueOf()
Why is that? Well that’s because those methods are available on both the string type and the number type. The toFixed() method is only available on the number type.
So the issue above is that TypeScript caon’t work out whether amount is a number or a string, so calling toFixed() might fail if a string is passed.
The typeof operator
function getCurrencyInPounds(amount: number | string) {
if (typeof amount === "number") {
amount.toFixed(2);
}
return `£${amount}`;
}
This adding
£to the start of the string is a very naive approach to handling currency. This has been done to keep the example code blocks as short as possible in these sections. However if you’re curious to see a more rounded implementation I’ve included one at the end of this post.
Using the typeof keyword allows us to first confirm the type of a value before we attempt to perform operations on it. The typeof keyword checks the type of any value and allows us to perform conditionals with it.
An important point here is that typeof can only be used with primitive types, we can’t do something like this:
type Money = { amount: number };
function getCurrencyInPounds(amount: number | string | Money) {
if (typeof amount === "Money") {
return `£${amount.toFixed(2)}`;
} else if (typeof amount === "number") {
return `£${amount.toFixed(2)}`;
} else {
return `£${parseFloat(amount).toFixed(2)}`;
}
}
Later in this post we will discuss the
inoperator which actually will allow us to do something similar to the example above.
In we try the code snippet in VS Code we will get the following error:
This comparison appears to be unintentional because the types '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' and '"Money"' have no overlap.
Which shows us what values typeof can actually be which are all of the primitive types, not any custom types we create. We can also notice null is missing from the list above, why is that?
Well, typeof null actually equals object and later on in the post we will see an example of where this can throw an error when used incorrectly.
The instanceof operator
import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common";
class NotFound extends Error {}
class ValidationError extends Error {}
@Catch()
export class HttpErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
if (exception instanceof ValidationError) {
return res.status(400).json({ error: exception.message });
}
if (exception instanceof NotFound) {
return res.status(404).json({ error: exception.message });
}
if (exception instanceof Error) {
return res.status(500).json({ error: "Internal Server Error" });
}
return res.status(500).json({ error: "Internal Server Error" });
}
}
By using the instanceof operator we’re able to first check whether a value is an ‘instance of’ another value. Important to note we used the term ‘value’ here, we’re not importing types nor are we checking against types. The instanceof operates on actual values, classes in the above example.
Why does it operate on values?
Well behind the scenes the instanceof operator performs a prototype-chain membership test. Let us see what this means, using exception instanceof ValidationError as an example:
- First it will read
ValidationError.prototypeso it knows what to look for. - Now it walks through
Object.getPrototypeOf(exception)checking each object. - Returns
trueif it finds the same object asValidationError.prototype. - Returns
falsewhen it reachesnull, the end of the chain.
If the
ValidationErrorclass definesstatic [Symbol.hasInstance](value)then instead of walking the prototype-chaininstanceofwill call that hook instead and use thebooleanresult
The instanceof operator has many applications across many domains, so it is important for us to understand how this works.
The in operator
type Money = { value: number };
// NOTE - This code isn't fully safe
function getCurrencyInPounds(amount: number | string | Money) {
if (typeof amount === "object" && "value" in amount) {
return `£${amount.value.toFixed(2)}`;
} else if (typeof amount === "number") {
return `£${amount.toFixed(2)}`;
} else if (typeof amount === "string") {
return `£${parseFloat(amount).toFixed(2)}`;
}
}
So as promised earlier, we’re returning to this example and introducing the in operator. We can use the in operator to check if a property exists, like above we’re checking if value exists in amount.
Now while the above code works there are a few issues. As we discussed before typeof null is equivalent to object so if amount was ever null this block of code would throw.
Next, the in operator only checks whether a property exists it DOES NOT check if that property has a value. So in the above example, if value was undefined we’d then try to call toFixed(2) on it, which would throw.
There are other issues with the code block as well; lack of exhaustiveness, naive currency approach,
parseFloatmay not work as expected, but for the sake of this post we’ll focus on the issues around usingin
So why did we look at that example if it isn’t safe code? Well it is useful for us to examine common pitfalls to highlight issues we may run into. Once again, we’ll return to this code in the next section to round it out and fix these issues.
In the meantime, we will now look at how we can use the in operator.
if (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
window.isSecureContext
) {
navigator.serviceWorker.register("/sw.js").catch(console.error);
}
So above we have a fairly standard pattern where in is used to feature-detect service workers so we can safely register our service worker.
In this context feature-detect simply means to check we’re in a browser, not SSR, and that the API exists.
So really the in operator provides us a way to check whether a property (feature-detect) exists on some object when we can’t use a more discriminant method.
Defining our own type predicates
type Money = { value: number };
// Our type predicate
function isMoney(x: unknown): x is Money {
return (
x !== null &&
typeof x === "object" &&
"value" in x &&
typeof x.value === "number"
);
}
function getCurrencyInPounds(amount: number | string | Money) {
if (isMoney(amount)) {
return `£${amount.value.toFixed(2)}`;
} else if (typeof amount === "number") {
return `£${amount.toFixed(2)}`;
} else if (typeof amount === "string") {
return `£${parseFloat(amount).toFixed(2)}`;
}
}
A type predicate is a function we create that accepts a value and performs a series of type checks on it and then returns the result of those type checks as a boolean. This means we can then use this predicate function within a conditional, like an if-statement.
In the above example the isMoney predicate is used to check whether the value provided matches what we expect from the Money type.
We solve all of the previous concerns; we make sure that the amount is not null and we check that amount.value is actually a number. By moving this all out to a separate function we keep our getCurrencyInPounds function clean and the isMoney predicate is now shareable across the codebase.
Now of course the above example is an incredibly naive approach to handling currency, in a real application we’d use Intl.NumberFormat, have to handle exchange rates if dealing with other currencies and use a better method than parseFloat to handle commas in numbers represented as a string. However the goal of this example was to highlight type-narrowing so we could focus on that.
However, in the interest of completeness a more rounded implementation would look like this:
type Money = { value: number };
const GBP = new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function isMoney(x: unknown): x is Money {
return (
x !== null &&
typeof x === "object" &&
"value" in x &&
typeof (x as { value: unknown }).value === "number" &&
Number.isFinite((x as { value: number }).value)
);
}
function parseNumericString(s: string): number | null {
const cleaned = s.replace(/[^\d+\-eE.]/g, ""); // strip £, $, commas, spaces, etc.
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
export function getCurrencyInPounds(amount: number | string | Money): string {
if (typeof amount === "number") {
if (!Number.isFinite(amount)) throw new Error("Invalid number");
return GBP.format(amount);
}
if (typeof amount === "string") {
const n = parseNumericString(amount);
if (n == null) throw new Error("Invalid amount string");
return GBP.format(n);
}
if (isMoney(amount)) {
return GBP.format(amount.value);
}
throw new Error("Unsupported amount");
}
Conclusion
Hopefully we now understand better the power of TypeScript’s narrowing. It just magic, it is a powerful tool we developers can use to perform runtime checks that the compiler understands.
Using typeof, instanceof, in and type predicates can unlock so much for us as developers and help us build a more robust, maintainable, type system.