Custom Type Guards - TypeScript Narrowing #3

TypeScript Narrowing #3

Hey, welcome to another article in our TypeScript Narrowing series. In this article, I'll explain:

  1. Type predicates
  2. How to create your own guards
  3. How to create a guard by exclusion

This is the third article in our series, if you haven't watched the previous ones, I highly recommend that you do, they lay out solid fundamentals for narrowing. I'll leave a link for them in the references.

I'm Lucas Paganini, and on this site, we release web development tutorials. If that's something you're interested in, leave a like and subscribe to the newsletter.

LinkType Predicates

In the last article, we explored the fundamental type guard operators. Now I'd like to show you type guard functions.

For example, if you need to check if a variable called value is a string, you could use the typeof operator. But what you could also do, is to create a function called isString() that receives an argument and returns true if the given argument is a string.

TypeScript
const isString = (value: any): boolean => typeof value === 'string';

Remember our formatErrorMessage() function from the last article?

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;
}

Let's remove the typeof operator from it and use isString() instead.

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 (isString(value)) {
    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;
}

It's the same code, we've just isolated the guard in a function, right? No. It breaks. TypeScript is not narrowing the type to string, the guard is not working.

Here's the thing, isString() is returning a boolean and we know what that boolean means.

TypeScript
const isString = (value: any): boolean => typeof value === 'string';

It means that the argument is a string. But TypeScript doesn't know what that boolean means, so let's teach it.

Instead of saying that our function returns a boolean, we need to say that our function returns the answer to the question: "is this argument a string?".

Given that the name of our argument is value, we do that with the following syntax: value is string.

TypeScript
const isString = (value: any): value is string => typeof value === 'string';

Now TypeScript understands that isString() is a type guard and our formatErrorMessage() function compiles correctly.

The return type of our isString() function is not just a boolean anymore, it's a "Type Predicate".

So to make a custom type guard, you just define a function that returns a type predicate.

All type predicates take the form of { parameter } is { Type }.

LinkThe unknown Type

A quick tip before we continue:

Instead of using the type any in our custom guard parameter, our code would be safer if we used the unknown type.

TypeScript
const isString = (value: unknown): value is string => typeof value === 'string';

I made a one-minute video explaining the differences between any and unknown, the link is in the references.

LinkCustom Guards

Let's exercise our knowledge by converting all the checks in our formatErrorMessage() function to custom guards.

We already have a guard for strings, now we need guards for Warning, Error and falsy types.

Error Guard

The guard for Error is pretty straightforward, we just isolate the instanceof operator check in a function.

TypeScript
const isError = (value: unknown): value is Error => value instanceof Error;

Warning Guard

But the Warning guard, on the other hand, is not that simple.

TypeScript allowed us to use the in operator because there was a limited amount of types that our value parameter could be, and they were all objects.

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 (isString(value)) {
    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 (isError(value)) {
    return prefix + value.message;
  }

  // We will never reach here
  throw new Error(`Invalid value type`);
};

interface Warning {
  text: string;
}

But if we create a function and say that our parameter is unknown, then it could be anything. Including primitive types, and that would throw an Error because we can only use the in operator in objects.

TypeScript
interface Warning {
  text: string;
}

const isWarning = (value: unknown): value is Warning => 'text' in value; // Compilation error

The solution is to make sure our parameter is a valid object before using the in operator. And we also need to make sure that it's not null.

TypeScript
interface Warning {
  text: string;
}

const isWarning = (value: unknown): value is Warning =>
  typeof value === 'object' && value !== null && 'text' in value;

Falsy Guard

For the falsy values guard, we first need to define a type with values that are considered falsy.

TypeScript
type Falsy = false | 0 | -0 | 0n | '' | null | undefined;

I'm not including NaN here because there is no NaN type in TypeScript.

TypeScript
type Falsy = false | 0 | -0 | 0n | '' | null | undefined | ~~NaN~~;

The type of NaN is number and not all numbers are falsy, so that's why we're not dealing with NaN.

TypeScript
typeof NaN;
//=> number

There is a proposal to add NaN as a type – and also integer, float and Infinity. I think that's nice, it would be helpful to have those types.

TypeScript
// Proposal
type number = integer | float | NaN | Infinity;

I'll leave a link for the proposal in the references.

Anyway, now that we have our Falsy type, we can create a falsy values guard.

Remember, a value is falsy if it's considered false when converted to a boolean. So, to check if our value is falsy, we can use abstract equality to see if it gets converted to false.

TypeScript
type Falsy = false | 0 | -0 | 0n | '' | null | undefined;

const isFalsy = (value: unknown): value is Falsy => value == false;

LinkformatErrorMessage() with Custom Guards

That's it, we now have all the custom guards that we need for our formatErrorMessage() function.

TypeScript
// FUNCTION
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 (isFalsy(value)) {
    return prefix + 'Unknown';
  }

  // If it's a string, return the string with the prefix
  if (isString(value)) {
    return prefix + value;
  }

  // If it's a Warning, return the Warning.text with the prefix
  if (isWarning(value)) {
    return prefix + value.text;
  }

  // If it's an Error, return the Error.message with the prefix
  if (isError(value)) {
    return prefix + value.message;
  }

  // We will never reach here
  throw new Error(`Invalid value type`);
};
TypeScript
// GUARDS
const isString = (value: unknown): value is string => typeof value === 'string';

const isError = (value: unknown): value is Error => value instanceof Error;

interface Warning {
  text: string;
}

const isWarning = (value: unknown): value is Warning =>
  typeof value === 'object' && value !== null && 'text' in value;

type Falsy = false | 0 | -0 | 0n | '' | null | undefined;

const isFalsy = (value: unknown): value is Falsy => value == false;

LinkBONUS: Narrowing by Exclusion

Before we wrap this up, I want to show you something.

There is a limited list of falsy values, right?

markdown
1. `false`
2. `0` `-0` `0n` representations of zero
3. ```` `""` `''` empty string
4. `null`
5. `undefined`
6. `NaN` not a number

But truthy values, on the other hand, are infinity. All values that are not falsy, are truthy.

So, how would make a type guard for truthy values?

Truthy Guard

The trick is to exclude the falsy types.

Instead of checking if our value is truthy, we check that it's _not_ falsy.

TypeScript
type Truthy<T> = Exclude<T, Falsy>;

const isTruthy = <T extends unknown>(value: T): value is Truthy<T> =>
  value == true;

// Test
const x = 'abc' as null | string | 0;
if (isTruthy(x)) {
  x.trim(); // `x: string`
}

I use this trick a lot, and we'll see it again in future articles.

LinkConclusion

References and other links are below.

If you haven't already, please like, subscribe, and follow us on social media. This helps us grow, which results in more free content for you. It's a win-win.

And if your company is looking for remote web developers, I and my team are currently available for new projects. You can contact us on lucaspaganini.com.

Have a great day, and I'll see you soon.

  1. 1min JS - Falsy and Truthy
  2. 1min TS - Unknown vs Any
  3. TypeScript Narrowing Series
  4. TypeScript Narrowing Part 1 - What is a Type Guard
  5. TypeScript Narrowing Part 2 - Type Guard Operators

LinkReferences

  1. TypeScript docs on narrowing
  2. TypeScript docs type predicates
  3. The in operator on MDN
  4. TypeScript proposal to add NaN as a type

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