Asynchronous type guards - TypeScript Narrowing #7

TypeScript Narrowing #7

Hey, welcome to part 7 of our TypeScript narrowing series.

Before we start, let me say this: It is recommended that you read the articles of this series in sequence, but you don't really need to read all of them to understand this one. What you do need, is to read part 3 and 6, at least. Otherwise, you'll probably feel lost here. Ok?

Today, we’ll talk about a highly requested feature in TypeScript: asynchronous type guards!

We just have one small problem… it’s still just a feature request, there’s no official support for asynchronous type guards yet.

But as developers, we can’t just wait for someone else to fix our problems, sometimes (most times) we need to find a solution ourselves, and with the tools we currently have available.

And that’s exactly what we’re going to do. I’ll show you the cleanest workaround for asynchronous type guards that I came up with. It is also a solution that will be easy to refactor once we do get support for asynchronous type guards in TypeScript.

If you have a different solution, please, leave a comment and we can discuss it.

I'm Lucas Paganini, and in this blog, we release web development tutorials. Subscribe if you're interested in that.

LinkThe Goal

In an ideal world, we would be writing asynchronous functions that return a type predicate wrapped in a Promise.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"

We can’t do that yet, but we can strive to get as close to it as possible. So, let's look for a workaround that is close to this ideal implementation.

Also, you might be wondering why would we ever asynchronously check if a value is a string. Well, I too wonder that. Why would we ever do that?

The thing is, I have a real world use case where asynchronous type guards would be useful. And I’ll show you that scenario. But I want us to first understand our implementation of asynchronous type guards. So let’s stick with our fictional isStringAsync function for now.

LinkThe Workaround

We can't currently return a type predicate wrapped in a Promise, but we can return a boolean wrapped in a Promise.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"
TypeScript
const isStringAsync = async (value: unknown): Promise<boolean> =>
  typeof value === 'string';

Now, as you might have guessed, that boolean means nothing to TypeScript. It performs no type narrowing in our variable.

TypeScript
const isStringAsync = async (value: unknown): Promise<boolean> =>
  typeof value === 'string';

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString) {
    aaa; // <- aaa: string | number | Date
  } else {
    aaa; // <- aaa: string | number | Date
  }
});

That's sad. It seems like we can only have synchronous type guards...

But wait. In the last article, we learned that we can have functions that create new type guards. So maybe we could have a function that asynchronously creates a synchronous type guard.

See, instead of returning the Promise of a type predicate, which we can't do yet.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"

We could return the Promise of a function that returns a type predicate.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<(v: unknown) => v is string> =>
  (v): v is string =>
    typeof value === 'string';

And it actually works!

TypeScript
const isStringAsync =
  async (value: unknown): Promise<(v: unknown) => v is string> =>
  (v): v is string =>
    typeof value === 'string';

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString(aaa)) {
    aaa; // <- aaa: string
  } else {
    aaa; // <- aaa: number | Date
  }
});

And it's so close to the ideal scenario, that it will be very easy to refactor our code once we do get native support for asynchronous type guards.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<v is string> =>
    typeof value === "string"

const aaa = 1 as number | string | Date
isStringAsync(aaa).then(isString => {
  if (isString) {
    aaa // <- aaa: string
  } else {
    aaa // <- aaa: number | Date
  }
})

LinkMotivations for Asynchronous Type Guards

Awesome! Now that we got the implementation, let's discuss a real world scenario where it would really be beneficial for us to have asynchronous type guards. Because checking if a value is a string asynchronously, is not a good example.

I'll give you an example inspired by the one that Dominik Głodek gave when he made the feature request for asynchronous type guards:

You're writing an API endpoint that receives data to create a user. The user data consists of an email and a password.

TypeScript
interface User {
  email: string;
  password: string;
}

You can only save users to the database if they are valid. For a user to be valid, it needs to meet 2 criteria:

  1. The password should have at least 8 characters
  2. The email cannot already belong to another user

The first check could be done synchronously, so let's start with that. We will create an assertion function called validateUser that checks that our object complies with the User interface and with the password validation.

TypeScript
type ValidateUser = (value: unknown) => asserts value is User;

const validateUser: ValidateUser = (value) => {
  assertHasProperties(['email', 'password'], value);
  assertIsString(value.email);
  assertIsString(value.password);

  // 1. The password should have at least 8 characters
  if (value.password.length < 8) throw Error('Password is too short');
};

👉 If you don't know what an assertion function is, we have a full article on this topic. It's the fifth article of this series. I'll leave a link for it in the references.

Before we add our asynchronous validation to make sure the user email doesn't already belong to another user, let's see what would happen if we tried to use our assertion function as it is right now.

TypeScript
const saveUserToDatabase =
  async (user: User): Promise<void> => { ... }

type ValidateUser =
  (value: unknown) =>
    asserts value is User
const validateUser: ValidateUser = value => { ... }

let user: unknown
validateUser(user)
await saveUserToDatabase(user)

As you can see, TypeScript thinks it's all good. There are no compilation errors. saveUserToDatabase is expecting a User and that's what it's getting. But it doesn't know that this User has not been fully validated yet.

Actually, TypeScript would think it's all good even if we did not validate the password length. In short, TypeScript is only verifying that our value complies with the User interface, it's not verifying if it passes the validation criteria.

Could we somehow tell TypeScript when a value not only complies with the User interface but is also a fully validated user? Well... yeah. We can.

We can create a secret property to indicate that the password length has been validated and another to indicate that the email doesn't belong to another user. Also, we can have a union type, called ValidatedUser which is equal to an User that has both validations.

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol;
};

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol;
};

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>;

With that in place, we can change the signature of validateUser to assert that the value is not just a User, but a PasswordValidated<User>.

TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is User
const validateUser: ValidateUser = value => { ... }
TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }

And we can also change the signature of saveUserToDatabase to make it only accept users that have already been fully validated.

TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

With that structure in place, I'm weirdly happy to say that our code would NOT compile.

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol
}

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol
}

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>

const validateUser =
  (value: User): asserts value is PasswordValidated<User> => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

let user: unknown
validateUser(user)
await saveUserToDatabase(user)
// Compilation error: Argument of type 'PasswordValidated<User>' is not assignable to parameter of type 'ValidatedUser'.

Yaaaay 🎉🎉🎉

For it to compile again, we need to create an asynchronous assertion function using the same trick that we used for asynchronous type guards.

TypeScript
type ValidateUser = (
  value: unknown
) => asserts value is PasswordValidated<User> & UniqueEmailValidated<User>;

const validateUserAsync = async (value: unknown): Promise<ValidateUser> => {
  // If we throw an error, save it to throw later, in the assertion function
  let errorToThrow: Error | null = null;

  try {
    assertHasProperties(['email', 'password'], value);
    assertIsString(value.email);
    assertIsString(value.password);

    // 1. The password should have at least 8 characters
    if (value.password.length < 8) throw Error('Password is too short');

    // 2. The email cannot already belong to another user
    if (await emailIsAlreadyTaken(value.email))
      throw Error('Email is already taken');
  } catch (error) {
    errorToThrow = error;
  }

  return (v) => {
    if (errorToThrow) throw errorToThrow;
  };
};

Ok, I think we're good now. Calling validateUserAsync returns the Promise of a synchronous assertion function. Which we then use to assert that the value is a ValidatedUser.

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol
}

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol
}

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>

type ValidateUser =
  (value: unknown) =>
    asserts value is
      PasswordValidated<User> & UniqueEmailValidated<User>
const validateUserAsync =
  async (value: unknown): Promise<ValidateUser> => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

let user: unknown
const validateUser: ValidateUser = await validateUserAsync(user)
validateUser(user)
await saveUserToDatabase(user)

Cool, now we're protecting ourselves from performing database operations with unvalidated resources. That's a real world use case for asynchronous type guards and a whole bunch of advanced TypeScript madness.

makeAsyncPredicateFunction

And you know what? I love you. So I went ahead and made our lives even easier.

I made an asynchronous higher order guard that creates asynchronous type guards for us. If you choose to use my utility function, it will be even easier to create asynchronous guards and refactor them once we get native support for them in TypeScript.

TypeScript
import { makeAsyncPredicateFunction } from '@lucaspaganini/ts';

const isStringAsync = makeAsyncPredicateFunction<string>(
  async (value) => typeof value === 'string'
);

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString(aaa)) {
    aaa; // <- aaa: string
  } else {
    aaa; // <- aaa: number | Date
  }
});

It works on Node and Browsers, you can install it with npm install @lucaspaganini/ts. But that library is a topic for the next article, so stay tuned.

LinkConclusion

References are in the description.

I made a comment in the feature request for asynchronous type guards, explaining the workaround I came up with. If you don't mind, it would be great if you give a thumbs up to the feature request and to my comment, to give them more traction. The link for it is also in the references.

If you enjoyed the content, you know what to do.

And if your company is looking for remote web developers, consider contacting me and my team on lucaspaganini.com.

In the next article, we'll talk about that library. And I'm planning it to be the last article of this series, so stick around, I think it'll be worth it.

Until then, have a great day, and I’ll see you soon.

LinkReferences

  1. Feature Request - Asynchronous Type Guards and Assertion Signatures TypeScript GitHub Repository
  2. My Comment Explaining this Workaround TypeScript GitHub Repository
  3. TypeScript Utilities Library - @lucaspaganini/ts GitHub Repository

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