Fundamental Type Guards - TypeScript Narrowing #2

TypeScript Narrowing #2

Hey!

Welcome to the second article in our TypeScript narrowing series, where we go from fundamentals to advanced use cases.

LinkOur 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.

TypeScript
const formatErrorMessage =
  (value: ???): string => { ... }

Our function should be able to receive multiple different types and return a formatted error message.

TypeScript
const formatErrorMessage =
  (value: null | undefined | string | Error | Warning): string => { ... }

Are you ready? 🥁

LinkThe typeof Operator Guard

We'll start by supporting only two types: string and Error.

TypeScript
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.

TypeScript
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.

TypeScript
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.

LinkThe 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

LinkEquality Narrowing

It's also very common to support optional arguments, which means, allowing value to be null or undefined.

TypeScript
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 ===:

TypeScript
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 !=

LinkTruthiness 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.

TypeScript
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.

LinkControl 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

LinkThe 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.

TypeScript
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.

LinkType 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.

LinkConclusion

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!

  1. 1min JS - Falsy and Truthy
  2. Null vs Undefined in JavaScript - Explained Visually
  3. TypeScript Narrowing Part 1 - What is a Type Guard

LinkReferences

  1. TypeScript docs on narrowing
  2. TypeScript docs on the never type
  3. The instanceof operator on MDN
  4. The typeof operator on MDN
  5. The in operator on MDN

Join our Newsletter and be the first to know when I launch a course, post a video or write an article.

This field is required
This field is required