DEV Community

Cover image for A Deep Dive into the satisfies operator in Typescript
Maina Wycliffe for This is Learning

Posted on • Originally published at allthingstypescript.dev

A Deep Dive into the satisfies operator in Typescript

In Typescript version 4.9, Typescript introduced the satisfies operator. Over the last few months, I have seen examples of it being used out in the world more often, and even I have found myself getting comfortable using it. In this issue, I want to do a deep dive and try and answer the following questions:

  • Why it exists

  • Why not annotations

  • Why not assertions

  • When to use it

What is the satisfies Typescript operator

According to the Typescript docs, the new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

So, what does this mean? When we add type annotations to a variable, Typescript doesn’t usually infer the Type to its narrowest possible value. So, if we say a variable’s type is a string, Typescript takes our word for it and checks the value we assign to it against the type annotation we provided, as shown below:

const str: string = "hello"
Enter fullscreen mode Exit fullscreen mode

In the case above, despite us using const, and having all the information at hand to narrow the above type to the hello literal type, Typescript doesn’t do that and instead takes our annotation as the type of the variable.

This is in contrast to when we allow type inference to work for us, instead of explicitly providing Type annotations, as shown below:

As you can see from the above example, when we use explicit type annotations, we lose the narrower type that we would have gotten if we had let Typescript infer the type for us.

Why is this important, you may ask? To answer this, let me show you an example, take the following two functions:

function a(input: string) {
  // do something
}

function b(input: "hello") {
  // do something 
}
Enter fullscreen mode Exit fullscreen mode

The first function accepts a string and the second accepts a literal type - hello. With the first variable, we annotated the type explicitly, this can be passed to the first function without any issues.

But in the second function, despite the first variable value being hello, Typescript will make you jump through to accept the variable, simply because it expects a narrower type than the type of string we provided.

In the second function, you would need to narrow the variable for it to work, as shown below:

if(str === "hello")  b(str);
Enter fullscreen mode Exit fullscreen mode

In our case above, we are using control flow to narrow the type of str variable to hello, from a broader type of string. You can learn more about type narrowing in this previous issue.

How does the satisfies operator help?

If you are wondering what this has to do with the satisfies operator, I am getting to the point in a moment.

What if you wanted to ensure that str is a string, without losing the narrowness of the inferred type? If you remember at the beginning, we said that the satisfies operator allows you to validate (think type-check) a variable type without changing the resulting type of that expression. The resulting type being the Type inferred by Typescript in the absence of explicit type annotation.

So, if we went back to our example, instead of using type annotation for our variable, and instead we used the satisfies operator, as shown below:

const str = "hello" satisfies string;
Enter fullscreen mode Exit fullscreen mode

Our variable wouldn’t carry the type of string as it did previously when we used type annotations, but the resulting inferred type is retained.

And this means, we can now pass the variable to the second function without needing to narrow the type.

const str = "hello" satisfies string;
//    ^? type = "hello"

// this works now
b(str);
Enter fullscreen mode Exit fullscreen mode

“Real-world” Example

Okay, I know what you are thinking, this isn’t a practical example. And that’s true, but I needed a straightforward example, that hopefully was easy to follow and understand, which I hope worked, and if it didn’t let me know in the comment section below.

Let’s see a more realistic example. Let’s say we want an object, with property names being string and values being either string, number, or boolean, as shown below:

const user = {
    firstName: "John",
    lastName: "Doe",
    age: 5,
    isMember: true
};
Enter fullscreen mode Exit fullscreen mode

With the above object, we might want to have Typescript type-check the content of the object. In such a situation we have two options:

  • providing explicit type annotations

  • or the satisfies operator.

If we use type annotations and try accessing one of the properties, Typescript doesn’t know the properties that are inside the object or their types, this is despite us explicitly adding them and adding them upfront.

const user: Record<string, string | number | boolean> = {
    firstName: "John",
    lastName: "Doe",
    age: 5,
    isMember: true
};
Enter fullscreen mode Exit fullscreen mode

In this case, Typescript goes with the type from our Type annotation for the property values.

This isn’t great, for one, we can include properties that don’t exist inside our user object or typos and which may introduce bugs within our codebase. However, if we went with the satisfies operator, then things work out quite differently and for the better.

const user = {
    firstName: "John",
    lastName: "Doe",
    age: 5,
} satisfies Record<string, string | number | boolean>
Enter fullscreen mode Exit fullscreen mode

If we try and access the property firstName, we get both auto-completion 👌🏾 and it has the correct type, as shown below:

We also can’t access properties we didn’t explicitly add to the user object.

And we also get the excess property checking we got when we provided explicit type annotations.

This has a few benefits:

  • Improved type safety as demonstrated in the last example. This is twofold:

    • validating the object properties and values are of certain types, and
    • avoiding changing the objects’ inferred type, which means the property value types are narrow instead of wide.
  • Reduced the amount of code we write - we don’t need to engage in type narrowing to use our properties above, they already have a narrow type.

Use Cases

Now that we have seen how we can some of the use cases of the satisfies operator.

Constraining the type of variable

We can use the satisfies operator to constrain or limit the types that can be used for a variable, without interfering with the type inference. A good example of this is our example above, where we limited our variable str to string, but let it be inferred as a narrower type by typescript, using the available information.

const str = "hello" satisfies string;
Enter fullscreen mode Exit fullscreen mode
Constraining the Property Names of an Object and Catching Excess Properties

We can use the satisfies operator to make sure our object only has the keys and nothing extra.

type Circle = {
  shape: "circle";
  radius: number;
}

const circle = {
  shape: "circle",
  radius: 4,
  anotherKey: 4
  // this will throw an error as shown bolow
} satisfies Circle;
Enter fullscreen mode Exit fullscreen mode

Constraining the values of Properties, the opposite of the above

The following will throw an error because the shape property is missing.

const circle = {
  radius: 4,
} satisfies Record<any, number>;
Enter fullscreen mode Exit fullscreen mode
Ensuring an Interface is Implemented
interface Person {
  delete: () => void;
  get: () => Record<string, unknown>
}

const thisPersion = {
  delete:  () => {};
  get: () => ({})
} satisfies Person;
Enter fullscreen mode Exit fullscreen mode

For more about the satisfies operator, check out the following issue, where the discussion for adding the satisfies operator to Typescript happened.

Conclusion

In this issue, we took a look at the satisfies Typescript operator, and how it can be used to validate a type, without changing the inferred type of the expression. This is different from how both type assertions and annotations work because Typescript takes that as the type of the object, which is sometimes not desired.

When using the satisfies operator, Typescript only type-checks our variable against the provided type while inferring the type using the information available (Learn more about Type Widening here).

The inferred type is usually narrower, meaning it can be used without the need for narrowing, which may not be the case for using assertions and annotations, while also type-checking it against a specific type, which ensures type safety isn’t compromised.


If you loved today’s content and would be interested in supporting my work, please consider supporting me (you can buy me a double latte, the juice that powers my creativity ☕️) through Github Sponsors.

Top comments (7)

Collapse
 
fyodorio profile image
Fyodor

That’s really awesome. The only problem is that we’ve got another way of creating debugging nightmares with this new approach 😅 imo, that’s the main issue with JS/TS — so many ways to implement [practically] the same stuff.

Collapse
 
brense profile image
Rense Bakker

Debugging nightmares because of satisfies operator? Please elaborate? Satisfies allows you to use a narrower type, which is better for debugging last time I checked?

Collapse
 
fyodorio profile image
Fyodor

Yes, edge cases in obscure type inference is one of the major sources of time-eating debuggin in larger TS apps. For simple cases that's quite straightforward, of course, I agree with you here.

Collapse
 
brense profile image
Rense Bakker

Great explanation of satisfies operator! Dev.to needs more of this! 👍

Collapse
 
mainawycliffe profile image
Maina Wycliffe

Thank you 🙏

Collapse
 
anodynos profile image
Angelos Pikoulas • Edited

What is the benefit compared to const circle: Circle = {...} ? Or const helloStr: string = 'hello' and so on?

Collapse
 
mainawycliffe profile image
Maina Wycliffe

As explained, explicit type annotation ties the type of variable to the annotation, i.e. helloStr can now only be a string, instead of the narrower literal type "hello" and if you wanted to use it in a place that requires the type "hello" (not string), you would need to narrow it down to hello (by checking if it's hello before passing it along).

In contrast, when using satisfies, you get the same type-safety that helloStr can only be a string, but the inferred narrower type of hello is retained, hence we are just checking to make sure that our variable type fits a certain type (in this case can only a string) without changing its narrower or more precise inferred type. For const helloStr, the type hello is more precise and much a better type than string.

This is because you should always prefer the narrower variable type whenever possible (link).