TypeScript: the satisfies operator

[2025-02-08] dev, typescript
(Ad, please don’t block)

TypeScript’s satisfies operator lets us check the type of a value (mostly) without influencing it. In this blog post, we examine how exactly it works and where it’s useful.

Notation used in this blog post  

For showing computed and inferred types in source code, I use the npm package asserttt – e.g.:

// Types of values
assertType<string>('abc');
assertType<number>(123);

// Equality of types
type Pair<T> = [T, T];
type _ = Assert<Equal<
  Pair<string>, [string,string]
>>;

What is the satisfies operator?  

The satisfies operator enforces at compile time that a given value is assignable to a given Type:

value satisfies Type
  • The result is still value. This operator has no effect at runtime.
  • The type of value is usually unchanged. There are a few exceptions, though, which we’ll look at later.

Syntax: no line terminator before satisfies  

This is not allowed:

const sayHello = (name) => `Hello ${name}!`
  // @ts-expect-error: Cannot find name 'satisfies'.
  satisfies (str: string) => string;

Parentheses can help:

const sayHello = (
  (name) => `Hello ${name}!`
) satisfies (str: string) => string;

A first example  

Let’s examine how various annotations affect the type of an object literal. We start without any annotations:

const point1 = { x: 2, y: 5 };
assertType<
  { x: number, y: number }
>(point1);

TypeScript generalizes the types of .x and .y to number. That changes if we use the as const annotation:

const point2 = { x: 2, y: 5 } as const;
assertType<
  { readonly x: 2, readonly y: 5 }
>(point2);

Now both properties are read-only and have narrow types. What happens if we want to check that the shape of our point is correct, via a new type Point?

type Point = { x: number, y: number };
const point3: Point = { x: 2, y: 5 } as const;
assertType<
  Point
>(point3);

point3 once again has a broader type. TypeScript infers the type name Point, which is an alias for the type that point1 had. We declared that variable without any type-level annotations.

satisfies lets us check that our point has the correct shape without discarding the narrow type that as const gives us:

const point4 = { x: 2, y: 5 } as const satisfies Point;
assertType<
  { readonly x: 2, readonly y: 5 }
>(point4);

How is satisfies different from as? On one hand, as generally changes the type of its left-hand side. On the other hand, it doesn’t type-check as thoroughly as satisfies:

// Should warn about missing property but doesn’t!
const point5 = { x: 2 } as const as Point; // OK
assertType<
  Point
>(point5);

In contrast, satisfies warns us if we omit property .y:

// @ts-expect-error: Type '{ readonly x: 2; }' does not satisfy
// the expected type 'Point'.
const point6 = { x: 2 } as const satisfies Point;

Why do we want the type of a point to be narrow? Actually, often we are perfectly fine with broader types. But there are use cases that require them to be narrow. We’ll look at those next.

Example: optional object properties  

The following code demonstrates that partialPoint1 having the broader type PartialPoint makes it less pleasant to work with:

type PartialPoint = { x?: number, y?: number };

const partialPoint1: PartialPoint = { y: 7 };

// Should be an error
const x1 = partialPoint1.x;
assertType<number | undefined>(x1);

// Type should be `number`
const y1 = partialPoint1.y;
assertType<number | undefined>(y1);

This is what happens if we use satisfies:

const partialPoint2 = { y: 7 } satisfies PartialPoint;

// @ts-expect-error: Property 'x' does not exist on type
// '{ y: number; }'.
const x2 = partialPoint2.x;

const y2 = partialPoint2.y;
assertType<number>(y2);

Type-checking object property values  

The following object implements an enum for text styles:

const TextStyle = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  Italics: {
    html: 'i',
    // Missing: latex
  },
};

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, "Bold" | "Italics"
>>;
  • On one hand, it’s neat that we can derive a type TextStyleKeys with property keys.
  • On the other hand, we forgot property TextStyle.Italics.latex and TypeScript didn’t warn us.

Improvement: a type for property values  

Let’s use the following type to check property values:

type TTextStyle = {
  html: string,
  latex: string,
};

Our first attempt looks like this:

const TextStyle: Record<string, TTextStyle>  = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  // @ts-expect-error: Property 'latex' is missing in type
  // '{ html: string; }' but required in type 'TTextStyle'.
  Italics: {
    html: 'i',
  },
};

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, string
>>;
  • Upside: We get a warning that a property is missing.
  • Downside: We can’t extract the property keys anymore – TextStyleKeys is now string.

Improvement: checking property values via satisfies  

Once again, satisfies can help us:

const TextStyle  = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  // @ts-expect-error: Property 'latex' is missing in type
  // '{ html: string; }' but required in type 'TTextStyle'.
  Italics: {
    html: 'i',
  },
} satisfies Record<string, TTextStyle>;

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, "Bold" | "Italics"
>>;

Now we get a warning for the missing property and a useful type TextStyleKeys.

Type-checking object property keys  

In the previous example, we checked the shapes of property values. Sometimes we are additionally dealing with a limited set of property keys and would like to check those – to avoid typos etc.

The following example is inspired by the pull request for satisfies and uses the type ColorName to check property keys and the type Color to check property values. We get an error because they key 'blue' is missing:

type ColorName = 'red' | 'green' | 'blue';
type Color =
  | string // hex
  | [number, number, number] // RGB
;
const fullColorTable = {
  red: [255, 0, 0],
  green: '#00FF00',
// @ts-expect-error:
// Type '{ red: [number, number, number]; green: string; }'
// does not satisfy the expected type 'Record<ColorName, Color>'.
} satisfies Record<ColorName, Color>;

What if we don’t want to use all of the keys but still get checks for typos? That can be achieved by making the record for the object partial (line A):

const partialColorTable = {
  red: [255, 0, 0],
  // @ts-expect-error: Object literal may only specify known
  // properties, but 'greenn' does not exist in type
  // 'Partial<Record<ColorName, Color>>'.
  // Did you mean to write 'green'?
  greenn: '#00FF00',
} satisfies Partial<Record<ColorName, Color>>; // (A)

We can also extract the property keys:

const partialColorTable2 = {
  red: [255, 0, 0],
  green: '#00FF00',
} satisfies Partial<Record<ColorName, Color>>;

type PropKeys = keyof typeof partialColorTable2;
type _ = Assert<Equal<
  PropKeys, "red" | "green"
>>;

Constraining literal values  

Consider the following function call:

JSON.stringify({ /*···*/ });

The parameter of JSON.stringify() has the type any. How can we ensure on our end that the object we pass to it has the right shape (no typos in the property keys etc.)? One option is to create a variable for the object:

const obj: SomeType = { /*···*/ };
JSON.stringify(obj);

Another option is to use satisfies:

JSON.stringify({ /*···*/ } satisfies SomeType);

The following subsections demonstrate this technique for several use cases.

Example: posting JSON via fetch()  

The following code was inspired by an article by Matt Pocock: We are using fetch() to post data to an API.

type Product = {
  name: string,
  quantity: number,
};

const response = await fetch('/api/products', {
  method: 'POST',
  body: JSON.stringify(
    {
      name: 'Toothbrush',
      quantity: 3,
    } satisfies Product
  ),
  headers: {
    'Content-Type': 'application/json',
  },
});

Example: export default  

Inline named exports can have type annotations:

import type { StringCallback } from './core.js';

export const toUpperCase: StringCallback =
  (str) => str.toUpperCase();

With a default export, there is no way to add one but we can use satisfies:

import type { StringCallback } from './core.js';

export default (
  (str) => str.toUpperCase()
) satisfies StringCallback;

Example: checking the static type of a class  

Consider the following class that implements the interface JsonInstance for converting instances to JSON:

class Person implements JsonInstance {
  static fromJson(json: unknown): Person {
    if (typeof json !== 'string') {
      throw new TypeError();
    }
    return new Person(json);
  }
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  toJson(): unknown {
    return this.name;
  }
}

/** Converting instances to JSON */
interface JsonInstance {
  toJson(): unknown;
}

The functionality for converting JSON to instances via Person.fromJson() is already there; we’d like to enforce and check it via the following interface:

/** Converting JSON to instances */
interface JsonStatic {
  fromJson(json: unknown): JsonInstance;
}

This doesn’t work because it hides the constructor and all properties except for the one in the interface:

const Person: JsonStatic = class implements JsonInstance {
  // ···
};

This is one option, but it is verbose and introduces unnecessary runtime code:

const personImplementsJsonStatic: JsonStatic = Person;

satisfies provides us with an elegant solution:

const Person = class implements JsonInstance {
  // ···
} satisfies JsonStatic;

satisfies isn’t always needed  

In this section, we look at code where it seems like we would need satisfies, but TypeScript already correctly narrows the code.

Example: union of string and number  

In the following code, we don’t need to use satisfies for str1 and str2 – they are already narrowed to string:

type StrOrNum = string | number;

const str1 = 'abc' as StrOrNum;
// @ts-expect-error: Property 'toUpperCase' does not exist on
// type 'StrOrNum'.
str1.toUpperCase();

const str2: StrOrNum = 'abc';
str2.toUpperCase(); // OK

let str3: StrOrNum = 'abc';
str3.toUpperCase(); // OK

Example: discriminated union  

In the next example, linkToIntro is also narrowed to one element of the discriminated union LinkHref:

type LinkHref =
  | {
    kind: 'LinkHrefUrl',
    url: string,
  }
  | {
    kind: 'LinkHrefId',
    id: string,
  }
;
const linkToIntro: LinkHref = {
  kind: 'LinkHrefId',
  id: '#intro',
};
// Type was narrowed:
assertType<
  {
    kind: 'LinkHrefId',
    id: string,
  }
>(linkToIntro);

satisfies can change inferred types  

While satisfies usually does not change the inferred type of a value, there are exceptions.

From type to literal type  

type Robin = {
  name: 'Robin',
};

const robin1 = { name: 'Robin' };
assertType<string>(robin1.name);

const robin2 = { name: 'Robin' } satisfies Robin;
assertType<'Robin'>(robin2.name);

From Array to tuple  

// No `satisfies`
const tuple1 = ['a', 1];
assertType<
  (string | number)[]
>(tuple1);

// Non-empty tuple
const tuple2 = ['a', 1] satisfies [unknown, ...unknown[]];
assertType<
  [string, number]
>(tuple2);

// Any tuple
const tuple3 = [] satisfies [unknown?, ...unknown[]];
assertType<
  []
>(tuple3);

// Any tuple
const tuple4 = ['a', 1] satisfies [] | unknown[];
assertType<
  [string, number]
>(tuple4);

satisfies does not change explicit types  

const tuple1: Array<string | number> = ['a', 1];

// @ts-expect-error: Type '(string | number)[]' does not satisfy
// the expected type '[unknown, ...unknown[]]'.
const tuple2 = tuple1 satisfies [unknown, ...unknown[]];

Type-level satisfaction check  

TypeScript does have an operation at the type level that is similar to satisfies, but we can implement it ourselves:

type Satisfies<Type extends Constraint, Constraint> = Type;

type T1 = Satisfies<123, number>; // 123
type _ = Assert<Equal<
  T1, 123
>>;

// @ts-expect-error: Type 'number' does not satisfy
// the constraint 'string'.
type T2 = Satisfies<123, string>;

Further reading  

Source of this blog post:

Material  

  • http://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/
  • https://medium.com/@bterlson/strongly-typed-event-emitters-2c2345801de8
  • https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
  • https://devblogs.microsoft.com/typescript/announcing-typescript-2-8-2/#conditional-types