TypeScript Narrowing #2
See this and many other articles at lucaspaganini.com
Hey!
Welcome to the second article in our TypeScript narrowing series, where we go from fundamentals to advanced use cases.
Our Goal
In this article, I want to show you the fundamental type guards in practice. To do that, we will build a function that formats error messages before showing them to the end-user.
const formatErrorMessage =
(value: ???): string => { ... }
Our function should be able to receive multiple different types and return a formatted error message.
const formatErrorMessage =
(value: null | undefined | string | Error | Warning): string => { ... }
Are you ready? 🥁
The typeof
Operator Guard
We'll start by supporting only two types: string
and Error
.
const formatErrorMessage =
(value: string | Error): string => { ... }
To implement that function, we need to narrow the string | Error
type to just a string
and deal with it, then narrow it to just an Error
and deal with it.
const formatErrorMessage = (value: string | Error): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
// If it's an Error, return the Error.message with the prefix
};
The first type guard that we'll explore is the typeof
operator. This operator allows us to check if a given value is a:
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
Let's use it in our function.
const formatErrorMessage = (value: string | Error): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error
};
What's happening here is that the typeof value === 'string'
statement is acting as a type guard for string
. TypeScript knows that the only way for the code inside that if statement to run is if value
is a string
so it narrows the type down to string
inside the if block.
Since we're returning
something, value
can't be a string
after that if statement, so the only type left is Error
.
The in
Operator Guard
Sometimes we're not so lucky. For example, let's add a new type to our function, a custom interface called Warning
.
const formatErrorMessage = (value: string | Error): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error
};
interface Warning {
text: string;
}
Now our code is broken.
const formatErrorMessage = (value: string | Error | Warning): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error | Warning
};
interface Warning {
text: string;
}
Before, our value
variable could only be an Error
instance after the if statement.
const formatErrorMessage = (value: string | Error): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error
};
interface Warning {
text: string;
}
But now, it can be Error | Warning
and the .message
property doesn't exist in a Warning
.
const formatErrorMessage = (value: string | Error | Warning): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error | Warning
};
interface Warning {
text: string;
}
The typeof
operator won't help us here because typeof value
would be "object"
for both cases.
const formatErrorMessage =
(value: string | Error | Warning): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value // <- value: string
}
// If it's a Warning, return the Warning.text with the prefix
if (???) {
return prefix + value.text
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message // <- value: Error | Warning
}
interface Warning {
text: string
}
One of the idiomatic ways of handling that situation in JavaScript would be to check if value
has the .text
property. If it does, it's a Warning
. We can do that with the in
operator guard.
const formatErrorMessage = (value: string | Error | Warning): string => {
const prefix = 'Error: ';
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value; // <- value: string
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text; // <- value: Warning
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message; // <- value: Error
};
interface Warning {
text: string;
}
This operator returns true
if the given object has the given property. In this case, if value
has the .text
property.
TypeScript knows that our if statement will only be true
if value
is a Warning
because that's the only possible type for value
that has a property called .text
, so it narrows the type down to Warning
inside the if block.
After the first, if statement, value
can be Warning | Error
. After the second if statement, it can only be Error
.
Equality Narrowing
It's also very common to support optional arguments, which means, allowing value
to be null
or undefined
.
const formatErrorMessage =
(value: null | undefined | string | Error | Warning): string => {
const prefix = 'Error: ';
// If it's null or undefined, return "Unknown" with the prefix
if (???) {
return prefix + 'Unknown'
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value // <- value: string
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text // <- value: Warning
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message // <- value: Error
}
interface Warning {
text: string
}
We could handle the undefined
case with the typeof
operator but that wouldn't work with null
.
By the way, if you want to know why it wouldn't work for null
and the differences between null
and undefined
. I have a very short and informative article explaining just that. I'll leave a link for it in the references.
What we could do that would work for null
and undefined
is to use equality operators, such as ===
:
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's null or undefined, return "Unknown" with the prefix
if (value === null || value === undefined) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value;
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text;
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message;
};
interface Warning {
text: string;
}
Our if statement will only be true
if value
equals null
or undefined
, so TypeScript narrows our type to null | undefined
.
That's called equality narrowing, and it also works with other comparison operators, such as:
- Not equals
!==
- Loose equals
==
- Loose not equals
!=
Truthiness Narrowing
But here's the thing. Equality narrowing is not the idiomatic JavaScript way of checking for null | undefined
. The idiomatic way of doing this is to check if the value is truthy.
I have a short article explaining what is truthy and falsy in JavaScript. I'll put the link in the references. It would be nice if you could go watch that real quick so that we have the definition of truthy and falsy fresh in our minds. Go ahead, I'm waiting.
Now that we all have the definition of truthy and falsy fresh in our minds, let me introduce you to truthiness narrowing.
Instead of using equality narrowing to check if value
equals null
or undefined
, we can just see if it's falsy.
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's falsy (null, undefined, empty string), return "Unknown" with the prefix
if (!value) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value;
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text;
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message;
};
interface Warning {
text: string;
}
We can do that by prefixing it with a logical NOT !
. That will convert the value to a boolean and invert it. If it's falsy, it'll be converted to false
and then inverted to true
.
Control Flow Analysis
So far, we've been avoiding a guard to check if value
is an instance of the Error
class. I told you how we're managing to do that. We are treating all the possible types so that there's only the Error
type left in the end.
That technique is very common in JavaScript, and it's also a form of narrowing. The correct term for what we've been doing is "Control Flow Analysis".
Control flow analysis is the analysis of our code based on its reachability.
TypeScript knows that we can't reach the first if statement unless value
is truthy.
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's falsy (null, undefined, empty string), return "Unknown" with the prefix
if (!value) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
// if (typeof value === 'string') {
// return prefix + value
// }
// If it's a Warning, return the Warning.text with the prefix
// if ('text' in value) {
// return prefix + value.text
// }
// If it's an Error, return the Error.message with the prefix
return prefix + value.message;
};
interface Warning {
text: string;
}
We can't reach the second if statement unless value
is a string
.
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's falsy (null, undefined, empty string), return "Unknown" with the prefix
if (!value) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value;
}
// If it's a Warning, return the Warning.text with the prefix
// if ('text' in value) {
// return prefix + value.text
// }
// If it's an Error, return the Error.message with the prefix
return prefix + value.message;
};
interface Warning {
text: string;
}
We can't reach the third if it's not a Warning
. So in the end, there's only one type left, it can only be an Error
.
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's falsy (null, undefined, empty string), return "Unknown" with the prefix
if (!value) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value;
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text;
}
// If it's an Error, return the Error.message with the prefix
return prefix + value.message;
};
interface Warning {
text: string;
}
Those types are being narrowed because TypeScript is using control flow analysis.
The instanceof
Operator Guard
But we don't need to rely on control flow analysis to narrow our type to Error
. We can do it with a very simple and idiomatic JavaScript operator. The instanceof
operator.
const formatErrorMessage = (
value: null | undefined | string | Error | Warning
): string => {
const prefix = 'Error: ';
// If it's falsy (null, undefined, empty string), return "Unknown" with the prefix
if (!value) {
return prefix + 'Unknown';
}
// If it's a string, return the string with the prefix
if (typeof value === 'string') {
return prefix + value;
}
// If it's a Warning, return the Warning.text with the prefix
if ('text' in value) {
return prefix + value.text;
}
// If it's an Error, return the Error.message with the prefix
if (value instanceof Error) {
return prefix + value.message;
}
// We will never reach here
throw new Error(`Invalid value type`);
};
interface Warning {
text: string;
}
Here we are checking if value
is an instance of the Error
class, so TypeScript narrows our type down to Error
. There is no type left after that last if statement, we will never reach any code that comes after it.
Type never
(15s)
If you're wondering what TypeScript considers to be the type of value
after all of our if statements, the answer is never
.
never
is a special type that represents something impossible, something that should never happen.
Conclusion
Those were the fundamental type guards, they are super useful, but they will only take you so far. In the next articles, I'll show you how to create custom type guards. Subscribe if you don't want to miss it.
References are below.
And if your company is looking for remote web developers, you can contact me and my team on lucaspaganini.com.
As always, have a great day, and I'll see you soon!
Related content
- 1min JS - Falsy and Truthy
- Null vs Undefined in JavaScript - Explained Visually
- TypeScript Narrowing Part 1 - What is a Type Guard
Top comments (0)