Assertion Functions or Assertion Guards - TypeScript Narrowing #5
TypeScript Narrowing #5
Welcome to the fifth article in our TypeScript narrowing series! Be sure to read the previous ones, if you haven't yet. Their links are in the references.
In this article, I'll show you assertion functions, also known as assertion guards.
I'm Lucas Paganini, and on this website, we release web development tutorials.
Assertion Functions vs Type Guards
The reason why assertion functions are also known as assertion guards is because of their similarity to type guards.
In our type guard for string
s, we return true
if the given argument is a string
and false
if it's not.
const isString = (value: unknown): value is string => typeof value === 'string';
TypeScriptconst isString = (value: unknown): value is string => typeof value === 'string';
If we wanted an assertion function instead of a type guard, instead of returning either true
or false
, our function would either return
or throw
. If it is a string
, it returns. If it's not a string
, it throws.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
TypeScriptfunction assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
If you call a function that throws if your value is not a string
, then all the code that comes after it will only run if your value is a string
, so TypeScript narrows our type to string
.
const x = 'abc' as string | number;
x; // <- x: `string | number`
assertIsString(x);
x; // <- x: `string`
TypeScriptconst x = 'abc' as string | number;
x; // <- x: `string | number`
assertIsString(x);
x; // <- x: `string`
To abstract this explanation: TypeScript uses control flow analysis to narrow our type to what was asserted. In this case, we have asserted that value
is a string
.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
TypeScriptfunction assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
We talk about control flow analysis in the second article of this series, the link for it is in the references.
Early Exits
Now, when and why would you want to use an assertion function instead of a type guard?
Well, the most popular use case for assertion functions is data validation.
Suppose you have a NodeJS server, and you’re writing a handler for user creation.
/** Expected body for POST /api/users */
interface CreatableUser {
name: string;
email: string;
password: string;
}
TypeScript/** Expected body for POST /api/users */
interface CreatableUser {
name: string;
email: string;
password: string;
}
The first thing you should do in your request handlers is to validate the data. If any of the fields are missing or invalid, you’ll want to throw an error.
function assertIsCreatableUser(value: unknown): asserts value is CreatableUser {
if (typeof value !== 'object') throw Error('Creatable user is not an object');
if (value === null) throw Error('Creatable user is null');
assertHasProps(['name', 'email', 'password'], value);
assertIsName(value.name);
assertIsEmail(value.email);
assertIsPassword(value.password);
}
TypeScriptfunction assertIsCreatableUser(value: unknown): asserts value is CreatableUser {
if (typeof value !== 'object') throw Error('Creatable user is not an object');
if (value === null) throw Error('Creatable user is null');
assertHasProps(['name', 'email', 'password'], value);
assertIsName(value.name);
assertIsEmail(value.email);
assertIsPassword(value.password);
}
When you have conditions to check at the beginning of your code, and you refuse to run if those conditions are invalid, that’s called an “early exit” and it’s the perfect scenario for an assertion function!
const userCreationHandler = (req: Request, res: Response): void => {
try {
// Validate the data before anything
const data = req.body
assertIsCreatableUser(data)
// Data is valid, create the user
...
} catch (err) {
// Data is invalid, respond with 400 Bad Request
const errorMessage =
err instanceof Error
? err.message
: "Unknown error"
res.status(400).json({ errors: [{ message: errorMessage }] })
}
}
TypeScriptconst userCreationHandler = (req: Request, res: Response): void => {
try {
// Validate the data before anything
const data = req.body
assertIsCreatableUser(data)
// Data is valid, create the user
...
} catch (err) {
// Data is invalid, respond with 400 Bad Request
const errorMessage =
err instanceof Error
? err.message
: "Unknown error"
res.status(400).json({ errors: [{ message: errorMessage }] })
}
}
If you want to know more about early exits, I have a one-minute video explaining this concept. The link is in the references.
/** Non empty string between 3 and 256 chars */
type Name = string;
function assertIsName(value: unknown): asserts value is Name {
if (typeof value !== 'string') throw Error('Name is not a string');
if (value.trim() === '') throw Error('Name is empty');
if (value.length < 3) throw Error('Name is too short');
if (value.length > 256) throw Error('Name is too long');
}
TypeScript/** Non empty string between 3 and 256 chars */
type Name = string;
function assertIsName(value: unknown): asserts value is Name {
if (typeof value !== 'string') throw Error('Name is not a string');
if (value.trim() === '') throw Error('Name is empty');
if (value.length < 3) throw Error('Name is too short');
if (value.length > 256) throw Error('Name is too long');
}
Issues with Control Flow Analysis
Maybe you've noticed that I'm using function declarations instead of function expressions. There's a reason for that.
First, knowing the differences between function declarations and functions expressions is very important. I'm sure most of you already know that, but if you don't, it's ok. I'll leave a link in the references for a one-minute video explaining their differences.
So, back to assertion functions. I'm using function declarations because TypeScript has trouble recognizing assertions functions during control flow analysis if they're written as function expressions.
Function declarations work because they're hoisted, so their types are declared previously and TypeScript likes that.
I think that's a bug. But I don't know if they're going to fix this. Currently, they say it's working as intended.
To work around that issue, I've found two alternatives:
- Use function declarations
// Alternative 1: Functions Declaration
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
TypeScript// Alternative 1: Functions Declaration
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw Error('value is not a string');
}
- Use function expressions with predefined types
// Alternative 2: Function Expressions with Predefined Types
// Predefined type
type AssertIsString = (value: unknown) => asserts value is string;
// Function expression with predefined type
const assertIsString: AssertIsString = (value) => {
if (typeof value !== 'string') throw Error('value is not a string');
};
TypeScript// Alternative 2: Function Expressions with Predefined Types
// Predefined type
type AssertIsString = (value: unknown) => asserts value is string;
// Function expression with predefined type
const assertIsString: AssertIsString = (value) => {
if (typeof value !== 'string') throw Error('value is not a string');
};
Function Expressions with Predefined Types
I prefer function expressions, so I'll go with the second alternative.
For that, instead of defining our function signature along with its implementation.
// DON'T: Signature with implementation
const assertIsString = (value: unknown): asserts value is string => {
if (typeof value !== 'string') throw Error('value is not a string');
};
TypeScript// DON'T: Signature with implementation
const assertIsString = (value: unknown): asserts value is string => {
if (typeof value !== 'string') throw Error('value is not a string');
};
We'll have to define its signature as an isolated type and cast our function to that type.
// Predefined type
type AssertIsString = (value: unknown) => asserts value is string;
// Function expression with predefined type
const assertIsString: AssertIsString = (value) => {
if (typeof value !== 'string') throw Error('value is not a string');
};
TypeScript// Predefined type
type AssertIsString = (value: unknown) => asserts value is string;
// Function expression with predefined type
const assertIsString: AssertIsString = (value) => {
if (typeof value !== 'string') throw Error('value is not a string');
};
Here's how it looks like if we wanted to use function expressions for our assertIsName
function:
// Predefined type
type AssertIsName = (value: unknown) => asserts value is Name;
// Function expression with predefined type
const assertIsName: AssertIsName = (value) => {
if (typeof value !== 'string') throw Error('Name is not a string');
if (value.trim() === '') throw Error('Name is empty');
if (value.length < 3) throw Error('Name is too short');
if (value.length > 256) throw Error('Name is too long');
};
TypeScript// Predefined type
type AssertIsName = (value: unknown) => asserts value is Name;
// Function expression with predefined type
const assertIsName: AssertIsName = (value) => {
if (typeof value !== 'string') throw Error('Name is not a string');
if (value.trim() === '') throw Error('Name is empty');
if (value.length < 3) throw Error('Name is too short');
if (value.length > 256) throw Error('Name is too long');
};
And we were also using an assertHasProps
function to check that our object has the properties that we expect. Maybe you're curious, so I'm showing it too because I think that function has an interesting signature.
// Predefined type
type AssertHasProps = <Prop extends string>(
props: ReadonlyArray<Prop>,
value: object
) => asserts value is Record<Prop, unknown>;
// Function expression with predefined type
const assertHasProps: AssertHasProps = (props, value) => {
// Only objects have properties
if (typeof value !== 'object') throw Error(`Value is not an object`);
// Make sure it's not null
if (value === null) {
throw Error('Value is null');
}
// Check if it has the expected properties
for (const prop of props)
if (prop in value === false) throw Error(`Value doesn't have .${prop}`);
};
TypeScript// Predefined type
type AssertHasProps = <Prop extends string>(
props: ReadonlyArray<Prop>,
value: object
) => asserts value is Record<Prop, unknown>;
// Function expression with predefined type
const assertHasProps: AssertHasProps = (props, value) => {
// Only objects have properties
if (typeof value !== 'object') throw Error(`Value is not an object`);
// Make sure it's not null
if (value === null) {
throw Error('Value is null');
}
// Check if it has the expected properties
for (const prop of props)
if (prop in value === false) throw Error(`Value doesn't have .${prop}`);
};
I'm also leaving a link to the GitHub issues and PRs related to this if you want to know more.
Assertions without a Type Predicate
Before we wrap this up, I want to show you a different signature for assertion functions:
type Assert = (condition: unknown) => asserts condition;
const assert: Assert = (condition) => {
if (condition == false) throw 'Invalid assertion';
};
TypeScripttype Assert = (condition: unknown) => asserts condition;
const assert: Assert = (condition) => {
if (condition == false) throw 'Invalid assertion';
};
This signature is weird, right? There is no type of predicate, what the hell are we asserting?
This signature means that the condition to check is already a type guard. For example, you could give it a typeof
expression, and it would narrow the type accordingly:
const x = 'abc' as string | number;
x; // <- x: `string | number`
assert(typeof x === 'string');
x; // <- x: `string`
TypeScriptconst x = 'abc' as string | number;
x; // <- x: `string | number`
assert(typeof x === 'string');
x; // <- x: `string`
Conclusion
Assertion functions are cool, right? People are just not used to them yet.
References are below. 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.
This is not the last article of this series. We have more to come! Until then, have a great day, and I’ll see you in the next one!
Related Content
- 1m JS: Early Exits
- TypeScript Narrowing pt. 1 - 8
References
- Assertion functions TypeScript Documentation
- Pull Request - Assertion Functions TypeScript GitHub Repository
- Pull Request - Error Messages for Assertion Functions that couldn't be Control Flow Analysed TypeScript GitHub Repository
- Issue - Assertion Functions and Function Expressions: TypeScript GitHub Repository
- Code Examples Lucas Paganini