Post

TypeScript's `satisfies readonly T[]`: What It Checks—and What It Doesn't

TypeScript's `satisfies readonly T[]`: What It Checks—and What It Doesn't

This TypeScript expression looks as if it creates a read-only array:

1
2
3
4
5
6
7
8
9
10
11
12
13
type MarketStatus = "open" | "paused" | "closed" | "resolved";

type Market = {
  id: string;
  status: MarketStatus;
};

const markets = [
  {
    id: "market_1",
    status: "open",
  },
] satisfies readonly Market[];

It does not.

The expression checks that markets is compatible with readonly Market[], while preserving the type TypeScript inferred for the array. That distinction is useful, but easy to miss.

What satisfies Actually Does

The satisfies operator asks TypeScript to verify that an expression can be assigned to a target type without replacing the expression’s inferred type with that target.

It catches invalid data:

1
2
3
4
5
6
7
const markets = [
  {
    id: "market_1",
    status: "running",
    // Type '"running"' is not assignable to type 'MarketStatus'.
  },
] satisfies readonly Market[];

It also catches missing required fields and, for object literals, unexpected properties in the relevant contextual type.

The useful mental model is:

1
2
: Type          use Type as the variable's declared type
satisfies Type  verify compatibility, then keep the inferred type

That preserved inference is the main reason to use satisfies for configuration objects and static data.

Why readonly Does Not Make This Array Read-Only

The target type says that a read-only consumer is allowed to receive the array. A mutable array is compatible with that promise: code that only reads it will not mutate it.

But satisfies does not change the array’s inferred type. In this example, the variable itself is still mutable:

1
2
3
4
5
const markets = [
  { id: "market_1", status: "open" },
] satisfies readonly Market[];

markets.push({ id: "market_2", status: "closed" }); // allowed

This behavior follows directly from the two parts of the expression:

  • readonly Market[] is the compatibility check.
  • satisfies avoids replacing the inferred type with readonly Market[].

So the code expresses “this value is safe to pass where a read-only market array is expected,” not “this variable cannot be mutated.”

Three Similar-Looking Choices

1. A type annotation

Use an annotation when the variable should be treated as a read-only array throughout the rest of the program:

1
2
3
4
5
6
const markets: readonly Market[] = [
  { id: "market_1", status: "open" },
];

markets.push({ id: "market_2", status: "closed" });
// Property 'push' does not exist on type 'readonly Market[]'.

The trade-off is that each element is viewed through the broader Market type. More specific information from the literal may be widened.

2. satisfies alone

Use satisfies when you want validation while retaining useful inference:

1
2
3
const markets = [
  { id: "market_1", status: "open" },
] satisfies readonly Market[];

This validates the data but does not make the inferred array read-only.

3. as const satisfies

Use a const assertion when the literal itself should retain deeply specific, read-only literal types:

1
2
3
4
5
6
const markets = [
  { id: "market_1", status: "open" },
] as const satisfies readonly Market[];

markets.push({ id: "market_2", status: "closed" });
// Property 'push' does not exist on the readonly tuple.

Now TypeScript infers a read-only tuple whose values remain literal types, approximately:

1
2
3
4
5
6
readonly [
  {
    readonly id: "market_1";
    readonly status: "open";
  },
]

This is powerful for fixed configuration, but it can be unnecessarily restrictive for data that is expected to change.

readonly Is Still Compile-Time Only

None of these TypeScript forms freezes a JavaScript object at runtime. Type annotations are erased when TypeScript is compiled.

If runtime immutability matters, use a runtime mechanism such as Object.freeze—and remember that Object.freeze is shallow unless you recursively freeze nested values.

1
2
3
const markets = Object.freeze([
  Object.freeze({ id: "market_1", status: "open" as const }),
]);

Runtime immutability and compile-time read-only types solve related but different problems.

A Practical Decision Guide

Choose based on the guarantee the code actually needs:

GoalPrefer
Validate static data and preserve inferencevalue satisfies Type
Expose an array through a read-only variableconst value: readonly T[]
Keep a fixed literal deeply specific and read-onlyvalue as const satisfies Type
Prevent mutation at runtimeObject.freeze or another runtime strategy

For a fixed list of seed markets or application configuration, this is often a good fit:

1
2
3
4
export const markets = [
  { id: "market_1", status: "open" },
  { id: "market_2", status: "paused" },
] as const satisfies readonly Market[];

For a list that will grow while the application runs, use a mutable array and expose read-only views at API boundaries instead of forcing the source data into a const assertion.

The Short Version

satisfies readonly Market[] means:

Check that this value can be used as a read-only array of markets, while preserving its inferred type.

It does not mean:

Turn this value into a read-only array.

That small distinction is exactly why satisfies is useful—and why it can be surprising.

This post is licensed under CC BY 4.0 by the author.