Understanding the TypeScript satisfies Operator

Unraveling the satisfies operator in TypeScript 4.9 – its purpose explained on a real-world example.


As per official documentation, Typescript satisfies operator was introduced in the TS 4.9 to solve the common dilemma:

How to ensure that some expression matches some type, but also to keep the most specific type of that expression for inference purposes?

For a while, even with an understanding of its theoretical benefits, I found the practical application of the satisfies operator elusive. Each time I encountered a potential use case, I hesitated, often revisiting documentation or seeking guidance from online articles to confirm my approach.

I decided to develop a hands-on example that finally clarified the concept for me.

The problem

Imagine we have the following Person type defined in our code:

interface Person {
  name: string;
  age: number;
  email: string;
  address: Address;
}

and our Address looks like this:

type Address = Record<string, string>;

Ideally, in most cases, we would prefer to define our types more precisely to have a more concrete structure to work with. However, for the purposes of this example, let's work with this broader type definition.

We can then create an object that conforms to the Person type by using the :<Type> type annotation. This approach not only provides automatic code suggestions as we type but also ensures that we include all required properties.

const person: Person = {
  name: "Michał",
  age: 25,
  email: "contact@michalweglarz.com",
  address: {
    city: "Kraków",
    country: "Poland",
  },
};

So far, nothing surpring here. Here's the thing, though: what happens when we try to access a property that doesn't really exist on this particular object?

const postalCode = person.address.postalCode;
//    ^? const postalCode: string

At first, it seems like everything's in order. The postalCode variable is typed as a string. No red squiggly line in sight. But why is it working even when the postalCode property hasn't been defined on the object? Well, this occurs because person.address is of the Record<string, string> type. Such a broad type definiton allows us to access any property, real or imagined, without TypeScript raising errors.

However, the real problem surfaces at runtime: even though Typescript suggests that postalCode variable is of string type, when we actually try to run the code, we'll see that the variable is undefined. This unexpected behavior can lead to unforeseen errors, potentially causing the entire application to crash.

postalCode.toLowerCase(); // ⛔️ undefined is not an object

Surprisingly, it turns out that our code is not as type-safe as we might have assumed.

This same issue also arises when using an explicit type assertion:

const person = {
 ...
} as Person;

See the full example

Solution

Type inference

We always have the option to create our object by relying solely on type inference.

const person = {
  name: "Michał",
  age: 25,
  email: "contact@michalweglarz.com",
  address: {
    city: "Kraków",
    country: "Poland",
  },
};
 
// typeof person
// {
//     name: string;
//     age: number;
//     email: string;
//     address: {
//         city: string;
//         country: string;
//     };
// }

This way, we won't be able to access non-existent properties:

const postalCode = person.address.postalCode;
//                                ~~~~~~~~~~
// Property 'postalCode' does not exist on type '{ city: string; country: string; }'

but thanks to the Typescript's structural typing, it would be sufficient, for example, to be able to pass it down to a function that expects its argument to be of Person type:

function getPersonAddress(person: Person) {
  return person.address;
}
 
const address = getPersonAddress(person); // No issue in this case

However, this approach also means we lose the ability to verify if the object we're creating is of the correct type until it's used in a specific type-restricted context.

If we make a typo:

const person = {
  name: "Michał",
  age: 25,
  eMail: "contact@michalweglarz.com",
// ^"eMail" instead of "email"
  ...
}

we might not even realize it until we attempt to pass this object to a function that expects an argument of the specific type:

const address = getPersonAddress(person);
//                               ~~~~~~
// Property 'email' is missing in type ...

Surely, this is not a satisfactory solution we can settle for.

See the full example

Satisfies

This is where the satisfies operator comes to the rescue:

const person = {
  name: "Michał",
  age: 25,
  email: "contact@michalweglarz.com",
  address: {
    city: "Kraków",
    country: "Poland",
  },
} satisfies Person;

What happens if we try to access person.address.postalCode now?

const postalCode = person.address.postalCode;
//                                ~~~~~~~~~~
// Property 'postalCode' does not exist on type '{ city: string; country: string; }'

We got a type error! Now, TypeScript understands that person.address.postalCode does not exist on the object we've just created. Simultaneously, it also recognizes that person correctly conforms to the Person type, along with all its associated implications.

// 🎉 Typo caught early!
const person = {
  name: "Michał",
  age: 25,
  eMail: "contact@michalweglarz.com",
  // ^ Object literal may only specify known properties, but 'eMail' does not exist in type 'Person'.
  //   Did you mean to write 'email'?
  address: {
    city: "Kraków",
    country: "Poland",
  },
} satisfies Person;

As we can see in this example, we get all the benefits of explicit type annotation and type inference in one handy operator!

See the full example

Summary

To slightly adapt the quote introduced at the beginning of this article, I find it convenient to think of the satisfies operator in the following way:

The satisfies operator combines the detailed type inference and the ability to ensure strict type conformance.

Having grasped this concept, its utility becomes evident, especially in scenarios involving broad types. With the satisfies operator, we can retain the necessary flexibility these types offer while simultaneously upholding strict type safety.