Typescript’s new ‘satisfies’ operator

Cefn Hoile
8 min readAug 29, 2022

--

The satisfies operator has arrived in Typescript 4.9. This article explains the purpose of the new keyword, illustrated by detailed, runnable code examples.

satisfies : The basics

Putting satisfies T after an expression asks the compiler to check your work. You will get helpful compiler errors if the expression doesn’t fulfil the requirements of type T. However, its not the same as assigning to a typed variable.

Assignment defeats type inference

Unlike assignment, satisfies doesn’t lose the inferred type of your value by changing and broadening the type — it just checks the shape, creating localised feedback if it is incorrect. The type of your value is unaffected.

If you do need to broaden a type, you can use satisfies inline to check the item shape before casting with as to ensure you don’t create runtime errors in production with an unsafe cast, like satisfies T as T.

Example Usage

The use of satisfies gives meaningful, compiler errors localised to the problematic data structure, rather than type errors elsewhere or worse, runtime errors in production, while preserving important forms of inference. Here are a few examples:

  • Preserving types of an as const expression. With types intact we can infer from the constant values in a data structure, while still checking it conforms to a broader type. We can change const TAX_CODES: readonly string[] = ["OT", "COT"] as const; to const TAX_CODES = ["OT", "COT"] as const satisfies readonly string[]; . This still checks the shape, but we don’t broaden the values to be stringalong the way. Typescript can therefore still infer that typeof TAX_CODES[number] is "OT" | "COT" and not just string.
  • Safe upcast. Sometimes we want a narrow value to have its type broadened. Upcasting it can add information that we intend it to be a member of a larger set. Instead of bypassing the compiler with the use of the error prone as T, we can check something satisfies T first. The use of as in the expressionconst maybeT = {foo:"bar"} as T tells the compiler we know best. It can create runtime errors in production when we didn’t guess the shape of T correctly, or if the shape of T is refactored. Replacing it with const sureT = {foo:"bar"} satisfies T as T ensures we get compiler errors whenever the shape is wrong.
  • Localised, inline compilation errors for our types even where there are no variable assignments or function argument types that would reveal a problem. The unreliable res.json({hello:world}); becomes res.json({hello:"world"} satisfies Greeting);

The rest of this article contains a section for each bulleted error above showing how satisfies can resolve production issues for that case.

Preserving the types of as const expressions

Powerful inference mechanisms and code path analysis mean a lot of Typescript is just plain javascript, but still type-safe. Good Typescript code normally omits explicit types where they can be derived directly from values in the code.

The example below defines a set of valid tax codes. Checking for a match in theTAX_CODES array can guard runtime operations that expect only valid tax codes — for example checking values parsed from a web request.

The values in our TAX_CODES array can also be used to define a TaxCode type for data structures or function arguments that should match valid tax codes. Typescript can derive the member type of an array from the array itself, ensuring there’s only one list of codes we need to maintain which is guaranteed to align the build time and run time definitions.

The utility type Member<A> below derives members from any array A. The typeA[number] is the type of value you get from accessingAwith a number index —in other words its members.

Our Member<A>utility works on any readonly unknown[] the broadest possible set of values. Using unknownallows arrays containing whatever kind of item, and thereadonly modifier means Member<A>can even infer from lists that lack writing operations such as member assignment, push() or pop().

Our aim was to limit the TaxCodetype to specific strings. But we made an error somewhere. In the Typescript playground for this code you can hover to see the TaxCode type is string | undefined instead of the union "OT" | "BR" | "C" | "COT" | "CBR" | "CDO" that we expected.

What’s going on? We pair on the problem. Our eagle-eyed colleague who hates unnecessary line breaks notices two consecutive commas "C",,"COT . If you leave a position empty in an array declaration, that makes it undefined. Oops, we thought we’d only put strings in there. By fixing the stray value, our TaxCode type is now inferred asstring but that’s still not what we need. Time to pair with that Typescript guru on the team.

By default, Typescript assumes that any literal values we assign to an array or object could be replaced with literals of the same type, and that an array could have members of that type added or removed. The TAX_CODES array ends up as a string[] since new strings could be written into it at any time, not just the ones we started with.

Let’s hint to Typescript that we don’t want this behaviour by using the as const modifier. This makes our data structure readonly so we will never change its members. Finally we can hover over our TaxCode in the playground and see it behaving as we wish.

Like every good engineer, we learn from our mistakes and find ways to avoid them the next time. Let’s help maintainers avoid that silly ,, and undefinedmistake. We will make sure TAX_CODES is explicitly declared as a readonly array of strings…

Oh no, we’ve got the problem back again! Setting the type of TAX_CODES to a readonly string array made our TaxCode type a string. Broadening the type of TAX_CODES ensured we couldn’t add undefined by mistake, but it also prevented Typescript from inferring types from the contents of the array. We erased the knowledge of what its members are!

Until Typescript 4.9 and thesatisfies keyword there was really no good way out of this hole. Lots of gnarly workarounds were flying around the community introducing unnecessary noop function calls, or unused non-null assertion variables which would be hard to explain to future maintainers and easily deleted. Surely the compiler should be able to check that an item conforms to a type without throwing away the item’s type information!

At last we can use the satisfies keyword to get support from the compiler without throwing away our array’s type information…

Bringing it all together we can use as const for inference, giving us typed code that’s safe at runtime. Here’s a complete example you can test out in the nightly Typescript playground which shows TaxCodetype helping us express a subset of PersonalTaxCodeusing the same trick.

Safe upcasts

A common use of inferred types is in a Store for state management, like Redux or Pinia. The state type held in the Store may be inferred from the initialStateconstructor argument. You can influence the type of state allowed in your store by broadening the type of your initialState. You want the state to be able to change from its initial values!

The store example below exploits Typescript Readonly to define an immutable state object without reactivity or reducers. Subscribers are notified when the state is replaced. The complete implementation is shown below

Lets define the state our store will be tracking, and a logger for reporting the state.

Now lets define the initial state and log it.

We already have a compiler error. It’s complaining that the type of store state isn’t compatible. It may have inferred the store state too narrowly from the INITIAL_STATE . So let’s follow the typing recommendations of Redux Toolkit and addas CharacterState to broaden its type.

Looks to have worked! No compiler errors. Now lets run the code. Our console logs…

Luke is on the planet undefined

Hmmm. I don’t remember that planet from Episode IV — A New Hope. The advice from Redux Toolkit was very bad. The use of as CharacterState to bypass the compiler has hidden an error for us to find at runtime in production! We don’t want to bypass build-time checks which would catch obvious errors like missing the name of the planet that Luke was grounded on. Adding satisfies CharacterState before as CharacterStaterevealed what our state was missing. Now we can get everything right.

And here’s the final complete example which can tell you a story in the nightly Typescript Playground

Localised, inline compilation errors

In Typescript, we often detect malformed data structures when we try to use them. A typed function signature will complain that values are not the right shape. However, we can help the reader if compiler errors are localised to the origin of the error — where the value was defined.

What’s more, function signatures at the boundaries of our systems such as JSON.stringify()or express res.json() may have no type constraints by design.

To improve the readability of our code, where we intend some data structure to conform to T we can annotate this using satisfies, like JSON.stringify({hello:"world"} satisfies Greeting) .

In this way we can be more sure we haven’t fat-fingered the structure we promised to an API client receiving our data over the wire.

A preferred pattern would be to create a strongly-typed function that guards then writes client data like this…

…but inlining the type check is better than nothing at all!

Summary

The satisfies operator is something I’ve been waiting for a long time. The style of type definitions I make have got me into the difficult territory more than once, where you want to have your type and eat it too. If you’re like me, then satisfies in Typescript 4.9 is really the icing on the cake.

If you want EVEN MORE satisfies goodness, visit my dev.to article on using satisfies never for exhaustiveness checking in Typescript unions.

If you need a full-stack devops Typescript expert on your team find out more at https://cefn.com/cv

--

--

Cefn Hoile

Roku Software Engineer https://cefn.com sculpting and supporting open source Prev: Cloud(@snyksec, @bbc, BT) Embedded(@ShrimpingIt,@vgkits,Make)