DEV Community

Lucas Paganini
Lucas Paganini

Posted on • Originally published at lucaspaganini.com

Fundamental Type Guards

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 => { ... }
Enter fullscreen mode Exit fullscreen mode

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 => { ... }
Enter fullscreen mode Exit fullscreen mode

Are you ready? 🥁

The typeof Operator Guard

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

const formatErrorMessage =
  (value: string | Error): string => { ... }
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

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

References

  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

Top comments (0)