Why do we have `satisfies`?

The `satisfies` typescript keyword has been out for a little while now, but why do we have it?

Reading Time
5 min (~892 words)
Tags
Typescript
Date
3/2/2023
Why do we have `satisfies`?

Overview
#

Part of what can make Typescript more difficult to learn is that it has a lot of ways to accomplish similar things. For example, there are three ways to assign a type to a value! For newcomers this can be confusing, but it's important to understand that each of these ways has a different purpose.

I'll start with a code example of good ol' colon annotation. This is the most common way to assign a type to a value:

1const record: Record<string, string> = {};
2record.id = '123'; // All good!

When you see a colon annotation, you can think of it as a way to say "this value must be of this type".

1// Type 'number' is not assignable to type 'string'.
2const str: string = 123;

This also means that you can assign a type to a value that is wider than the value! (but what does that mean?)

1let id: number | undefined = undefined;
2
3if (typeof id === 'undefined') {
4  id = 123;
5}

This can be useful when you want to assign a type to a value that will be reassigned, but it does also come with a downside. When using colon annotation, the type supercedes the value. This means that if you declare a wider type (such as Record) and then try to assign and access a narrower value, typescript will not help you.

1const routes: Record<string, {}> = {
2  "/": {}
3  "/about": {}
4  "/admin": {}
5};
6
7// No errors, because Record<string, {}> could contain this, even though our value doesn't!
8routes.thisDoesNotExist;

You also won't get autocomplete for the values, because the type is wider and not specific about what values might be in the object.

How does satisfies help?
#

The satisfies keyword is a way to say "this value satisfies this type". This means that the value must be of the type, but it can also be more specific. (the value supercedes the type!) This is useful when you want to ensure a value is of the correct type, while keeping it as narrow as possible.

1const routes = {
2  '/': {},
3  '/about': {},
4  '/admin': {}
5} satisfies Record<string, {}>;
6
7// Property 'thisDoesNotExist' does not exist on type '{ "/": {}; "/about": {}; "/admin": {}; }'.
8routes.thisDoesNotExist;

satisfies also helps protect you from doing the wrong thing, much like colon annotation. For example, if you try to assign a value that is not of the type, you will get an error.

1const routes = {
2  '/': {},
3  '/about': {},
4  // Type 'string' is not assignable to type '{}'.
5  '/admin': '123'
6} satisfies Record<string, {}>;

What about as?
#

The as keyword is basically a way lie to Typescript, or to say "I know what I'm doing, Typescript, so don't worry about it". Unlike satisfies and colon annotation, as doesn't actually check that the value is of the type, it just says "this value is of this type".

1const routes = {} as Record<string, { config: string }>;
2
3// No errors, but this will break at runtime!
4routes['/'].config;

There are also a few limitations to as - You can't force Typescript to convert a value to a type that is not compatible with the value. (Unless you use as-as which is a bit of a hack)

1// Type 'string' is not assignable to type 'number'.
2const num = '123' as number;
3
4// This is probably not good, but it works! (until you try to use it as a number)
5const num = '123' as unknown as number;

as does have some valid uses though, such as when you're converting an object to a known type:

1type User = {
2  id: number;
3  name: string;
4};
5
6const userBeingCreated = {} as User;
7
8userBeingCreated.id = 123;
9userBeingCreated.name = 'John';

but if you're using as to annotate most of your variables, you're probably doing something wrong. It might look safe, but as soon as you expand your User type, you'll start to have problems because nothing is ensuring that your variables are fulfilling the type.

1type User = {
2  id: number;
3  name: string;
4  age: number;
5};
6
7const userBeingCreated = {} as User;
8
9userBeingCreated.id = 123;
10userBeingCreated.name = 'John';
11// No errors, but we are not setting `age`!

In closing
#

I hope this helps you understand the difference between the different ways to assign types in Typescript, and why you might want to use one over the other.

One closing note on satisfies: Some people make the mistake of assuming they should use satisfies everywhere, but this is not the case. It's fine for simple cases like this:

1type User = {
2  id: number;
3  name: string;
4  age: number;
5};
6
7const defaultUser = {
8  id: 123,
9  name: 'John',
10  age: 30
11} satisfies User;

but most of the time when you assign a type to a variable, you want the type to be wider, for example:

1// colon annotation
2let id: number | undefined = undefined;
3if (typeof id === 'undefined') {
4  // This is fine, because `id` is a number or undefined, and we're assigning a number to it.
5  id = 123;
6}
7
8// satisfies
9let id = undefined satisfies number | undefined;
10if (typeof id === 'undefined') {
11  // Type 'number' is not assignable to type 'undefined'.
12  id = 123;
13}

satisfies should not be your default. It's for handling edge cases and when:

  • You want the EXACT type of the variable, not the WIDER type.
  • The type is complex enough that you want to make sure you didn't mess it up