DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 963,673 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Lucas Paganini
Lucas Paganini

Posted on • Originally published at lucaspaganini.com

Custom Type Guards

TypeScript Narrowing #3


See this and many other articles at lucaspaganini.com

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.

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

const isString = (value: any): boolean => typeof value === 'string';
Enter fullscreen mode Exit fullscreen mode

Remember our formatErrorMessage() function from the last article?

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

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

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

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.

const isString = (value: any): boolean => typeof value === 'string';
Enter fullscreen mode Exit fullscreen mode

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.

const isString = (value: any): value is string => typeof value === 'string';
Enter fullscreen mode Exit fullscreen mode

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

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

const isString = (value: unknown): value is string => typeof value === 'string';
Enter fullscreen mode Exit fullscreen mode

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

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

const isError = (value: unknown): value is Error => value instanceof Error;
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

interface Warning {
  text: string;
}

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

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.

interface Warning {
  text: string;
}

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

Falsy Guard

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

type Falsy = false | 0 | -0 | 0n | '' | null | undefined;
Enter fullscreen mode Exit fullscreen mode

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

type Falsy = false | 0 | -0 | 0n | '' | null | undefined | ~~NaN~~;
Enter fullscreen mode Exit fullscreen mode

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

typeof NaN;
//=> number
Enter fullscreen mode Exit fullscreen mode

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.

// Proposal
type number = integer | float | NaN | Infinity;
Enter fullscreen mode Exit fullscreen mode

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.

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

const isFalsy = (value: unknown): value is Falsy => value == false;
Enter fullscreen mode Exit fullscreen mode

formatErrorMessage() with Custom Guards

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

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

BONUS: Narrowing by Exclusion

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

There is a limited list of falsy values, right?

1. `false`
2. `0` `-0` `0n` representations of zero
3. ```

` `""` `''` empty string
4. `null`
5. `undefined`
6. `NaN` not a number
`

Enter fullscreen mode Exit fullscreen mode

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.


ts
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`
}


Enter fullscreen mode Exit fullscreen mode

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

Conclusion

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.

Related content

  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

References

  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

Top comments (0)

Update Your DEV Experience Level:

Settings

Go to your customization settings to nudge your home feed to show content more relevant to your developer experience level. πŸ›