Lesson 06 / 23
Const Types and as const
Single-value literal types, stopping widening with as const, deriving a union type from a fixed list, and how the satisfies operator differs from a type annotation.
Contents
For a discriminated union to work, its fields had to be a literal type. The type inference lesson, however, set a rule in the opposite direction: an object field’s type widens on its own and the literal type is lost.
This lesson resolves that tension. First it clarifies what literal types are, then
covers the as const notation that stops widening, and finally the satisfies
operator that checks a type without narrowing it.
A terminology note: literal type, as meant here, is a type containing a single value. It is a relative of the constant concept from the Programming Fundamentals course — a name bound to the same value throughout the program — but it is not the same thing; one concerns a value’s type, the other the immutability of a binding.
What a Literal Type Is
Every value has a type containing only itself. "C" is a string value, and "C" is
also a single-element type. Such types are called literal types, and they are defined
for strings, numbers, and booleans.
Literal types are not very useful on their own — the declaration const x: "C" = "C"
says nothing extra. Their value shows up in a union: type "C" | "Pa" | "%" is a
three-valued set that rejects a fourth value.
Widening and as const
Declaring the same object literal in two forms lets the inferred types be compared:
export const direct = { id: "s-01", unit: "C", value: 21.4 }; export const frozen = { id: "s-01", unit: "C", value: 21.4 } as const; export const units = ["C", "Pa", "%"]; export const unitsFixed = ["C", "Pa", "%"] as const;
Saved under the name const-types.ts and compiled with
tsc --strict --target es2022 --declaration --emitDeclarationOnly const-types.ts,
the produced const-types.d.ts file is:
export declare const direct: { id: string; unit: string; value: number; }; export declare const frozen: { readonly id: "s-01"; readonly unit: "C"; readonly value: 21.4; }; export declare const units: string[]; export declare const unitsFixed: readonly ["C", "Pa", "%"];
The as const notation does three things at once:
- It does not widen the fields’ types; each field keeps its own literal type.
- It marks every field readonly.
- It types an array literal as a tuple, not an array:
readonly ["C", "Pa", "%"]instead ofstring[].
The third point matters. The units array’s type has forgotten the element count;
the unitsFixed tuple’s type knows the count, the order, and the value at every
position.
The readonly marking gives a guarantee at the type level too:
const units = ["C", "Pa", "%"] as const; units.push("F");
f2.ts(2,7): error TS2339: Property 'push' does not exist on type 'readonly ["C", "Pa", "%"]'.
What as const Leaves at Runtime
The course’s axis has to be tested again. When the following file is compiled with
tsc --strict --target es2022 ac.ts:
const units = ["C", "Pa", "%"] as const; const measurement = { id: "s-01", unit: "C" } as const; console.log(units, measurement);
The JavaScript produced:
"use strict"; const units = ["C", "Pa", "%"]; const measurement = { id: "s-01", unit: "C" }; console.log(units, measurement);
The output of node ac.js is [ 'C', 'Pa', '%' ] { id: 's-01', unit: 'C' }.
as const has been entirely erased. The array is an ordinary array at runtime;
the push call is accepted by JavaScript. Readonlyness is a valid statement in the
code the compiler sees — not a runtime protection. Real immutability requires runtime
tools like Object.freeze, and even that is shallow.
The same boundary also shows up when a value marked as const is assigned to a type
that is not readonly:
type Unit = "C" | "Pa" | "%"; type Measurement = { id: string; value: number; unit: Unit }; const raw = { id: "s-01", value: 21.4, unit: "C" } as const; const record: Measurement = raw; console.log(record.unit); record.value = 22.1; raw.value = 22.1;
f6.ts(9,5): error TS2540: Cannot assign to 'value' because it is a read-only property.
The eighth line does not error. The record declaration’s type is Measurement, and
Measurement is not readonly; a non-readonly reference to the same object has been
obtained. Readonlyness is a property not of the object, but of the reference.
The fifth line being accepted shows the actual problem as const solves: without
as const, the unit field would widen to string and the assignment would be
rejected.
Deriving a Type From a Fixed List
From a tuple declared with as const, a type can be derived that is the union of its
elements:
const units = ["C", "Pa", "%"] as const; type Unit = (typeof units)[number]; const valid: Unit = "Pa"; console.log(valid, units.length); const invalid: Unit = "F";
f1.ts(7,7): error TS2322: Type '"F"' is not assignable to type '"%" | "C" | "Pa"'.
There are two steps. The typeof units notation takes the type of a value — here,
readonly ["C", "Pa", "%"]. Then the [number] index gives the union of the types of
all the tuple’s numerically indexed elements. The diagnostic’s "%" | "C" | "Pa" text
is the result of this derivation.
This pattern’s value is that it prevents the same information from being written twice. The value list stays in a single place; it can be used both at runtime (walked in a loop, used in validation) and at the type level. The “count all values” ability an enum gives is obtained here without producing any runtime code.
satisfies: Checking Without Narrowing
Writing a type on an object literal does two things at once: it checks the literal and it fixes the declaration’s type to that type. The second is not always wanted:
type Interval = { low: number; high: number }; const annotated: Record<string, Interval> = { temperature: { low: -40, high: 85 }, }; const checked = { temperature: { low: -40, high: 85 }, } satisfies Record<string, Interval>; console.log(annotated.humidity.high); console.log(checked.humidity.high);
f4.ts(12,21): error TS2339: Property 'humidity' does not exist on type '{ temperature: { low: number; high: number; }; }'.
The eleventh line does not error. The annotated declaration’s type is
Record<string, Interval>; this type says “an interval for every string key,” so a
humidity key counts as valid too. No such key exists at runtime, and the program
crashes.
The satisfies operator checks that the literal fits the given type, but does not
change the declaration’s type. The checked declaration’s type is the narrow type
inferred from the literal; access to a nonexistent key is caught on the twelfth line.
The checking direction works too:
type Interval = { low: number; high: number }; const intervals = { temperature: { low: -40, high: "85" }, } satisfies Record<string, Interval>; console.log(intervals.temperature.high);
f5.ts(4,28): error TS2322: Type 'string' is not assignable to type 'number'.
Comparison of the three notations:
| Notation | Checks the literal | Type of the declaration |
|---|---|---|
const x: T = {...} |
Yes | T |
const x = {...} as T |
No (forces it) | T |
const x = {...} satisfies T |
Yes | The narrow type inferred from the literal |
The middle row is the type assertion introduced in the first lesson, and it is a debt. The bottom row preserves both the check and the detail inference gives.
Summary
- A literal type is a type containing a single value; its usefulness shows up inside a union, and it is distinct from the “constant” concept, which is about the immutability of a binding.
- The
as constnotation stops widening, marks fields readonly, and types array literals as tuples. as constis entirely erased at runtime; readonlyness is a property of the reference, not the object, and does not provide real immutability.- From a fixed tuple, the union of its elements is derived with
(typeof x)[number]; the same information is kept in one place at both the value and type levels. - The
satisfiesoperator checks a literal against a given type but keeps the declaration’s narrow type; a type annotation checks and widens, a type assertion only forces.
Next Step
This topic built the types of individual values. Next comes naming and sharing these types: how is the contract a module exposes written, how are different implementations satisfying the same contract defined? The next topic starts with interfaces and turns the measurement record’s model into a reusable contract.
To keep your progress and take notes, Log in
My notes
Log in to take notes.