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

Assertion Functions or Assertion Guards

TypeScript Narrowing #5


See this and many other articles at lucaspaganini.com

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. Subscribe if you're interested in that.

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 strings, 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';
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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:

  1. 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');
}
Enter fullscreen mode Exit fullscreen mode
  1. 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');
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

  1. 1m JS: Early Exits
  2. TypeScript Narrowing pt. 1 - 8

References

  1. Assertion functions TypeScript Documentation
  2. Pull Request - Assertion Functions TypeScript GitHub Repository
  3. Pull Request - Error Messages for Assertion Functions that couldn't be Control Flow Analysed TypeScript GitHub Repository
  4. Issue - Assertion Functions and Function Expressions: TypeScript GitHub Repository
  5. Code Examples Lucas Paganini

Top comments (0)

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.