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.
The Goal
In an ideal world, we would be writing asynchronous functions that return a type predicate wrapped in a Promise
.
const isStringAsync =
async (value: unknown): Promise<value is string> =>
typeof value === "string"
TypeScriptconst 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.
The Workaround
We can't currently return a type predicate wrapped in a Promise
, but we can return a boolean
wrapped in a Promise
.
const isStringAsync =
async (value: unknown): Promise<value is string> =>
typeof value === "string"
TypeScriptconst isStringAsync =
async (value: unknown): Promise<value is string> =>
typeof value === "string"
const isStringAsync = async (value: unknown): Promise<boolean> =>
typeof value === 'string';
TypeScriptconst 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.
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
}
});
TypeScriptconst 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.
const isStringAsync =
async (value: unknown): Promise<value is string> =>
typeof value === "string"
TypeScriptconst isStringAsync =
async (value: unknown): Promise<value is string> =>
typeof value === "string"
We could return the Promise
of a function that returns a type predicate.
const isStringAsync =
async (value: unknown): Promise<(v: unknown) => v is string> =>
(v): v is string =>
typeof value === 'string';
TypeScriptconst isStringAsync =
async (value: unknown): Promise<(v: unknown) => v is string> =>
(v): v is string =>
typeof value === 'string';
And it actually works!
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
}
});
TypeScriptconst 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.
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
}
})
TypeScriptconst 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
}
})
Motivations 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.
interface User {
email: string;
password: string;
}
TypeScriptinterface 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:
- The password should have at least 8 characters
- 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.
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');
};
TypeScripttype 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.
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)
TypeScriptconst 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.
type PasswordValidated<T> = T & {
readonly __passwordValidated__: unique symbol;
};
type UniqueEmailValidated<T> = T & {
readonly __uniqueEmailValidated__: unique symbol;
};
type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>;
TypeScripttype 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>
.
type ValidateUser =
(value: unknown) =>
asserts value is User
const validateUser: ValidateUser = value => { ... }
TypeScripttype ValidateUser =
(value: unknown) =>
asserts value is User
const validateUser: ValidateUser = value => { ... }
type ValidateUser =
(value: unknown) =>
asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }
TypeScripttype 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.
type ValidateUser =
(value: unknown) =>
asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }
const saveUserToDatabase =
async (validUser: ValidatedUser): Promise<void> => { ... }
TypeScripttype 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.
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'.
TypeScripttype 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.
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;
};
};
TypeScripttype 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
.
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)
TypeScripttype 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.
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
}
});
TypeScriptimport { 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.
Conclusion
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.