Enums, const Assertions and satisfies
Representing fixed sets of values: TypeScript enums and their pitfalls, union-of-literals alternatives, as const, and the satisfies operator.
Fixed sets of values
Many values can only be one of a few options: a ticket is open, in
progress or resolved; a tariff is day or night; a unit is kWh or m³.
Typing such a value as string lets every typo through.
TypeScript has three ways to describe a closed set:
-
an
enum, a TypeScript feature that creates a type and an object (this page); -
a union of literal types,
type TariffCode = "day" | "night"(explained in Union, Intersection and Literal Types); -
a plain object with
as const, plus a type derived from it (this page).
All three catch typos at compile time. They differ in what exists at run time, and that difference matters more than it used to.
Numeric enums
An enum in TypeScript is a
declaration, enum Name { … }, that defines a set of
named constants and, at the same time, a type with that name and a
JavaScript object holding the constants. Without explicit values the
members are numbered from 0:
enum TicketStatus {
Open, // 0
InProgress, // 1
Resolved, // 2
}
function describe(status: TicketStatus): string {
switch (status) {
case TicketStatus.Open:
return "waiting for an engineer";
case TicketStatus.InProgress:
return "engineer on site";
case TicketStatus.Resolved:
return "power restored";
}
}
const status = TicketStatus.InProgress;
console.log(status);
console.log(TicketStatus[status]); // reverse mapping: number → name
console.log(describe(status));
1 InProgress engineer on site
The value printed is 1, not a name: a numeric enum
member is its number. The expression
TicketStatus[1] looks the name up again, which is
called the reverse mapping. A number literal outside the enum is
refused (since TypeScript 5.0; older articles claim any number is
accepted):
enum TicketStatus { Open, InProgress, Resolved }
const fromApi: TicketStatus = 7;
example.ts(3,7): error TS2322: Type '7' is not assignable to type 'TicketStatus'.
But a value whose type is plain number is still
accepted, and that is exactly the kind of value that arrives from an
API. The reverse mapping also puts the numbers into the object's
keys, which surprises code that loops over an enum:
enum TicketStatus { Open, InProgress, Resolved }
console.log(Object.keys(TicketStatus));
console.log(Object.values(TicketStatus).length);
const fromApi: number = JSON.parse('{"status": 7}').status;
const status: TicketStatus = fromApi; // compiles: number fits a numeric enum
console.log(status, TicketStatus[status]);
[ '0', '1', '2', 'Open', 'InProgress', 'Resolved' ] 6 7 undefined
String enums
Give every member a string value and you get a string enum. The values are readable in logs and JSON, and there is no reverse mapping, so the object has exactly one key per member:
enum Priority {
Low = "low",
High = "high",
}
console.log(Priority.High, Object.keys(Priority), Object.values(Priority));
high [ 'Low', 'High' ] [ 'low', 'high' ]
String enums have one unusual rule. Everywhere else TypeScript compares types by their shape, but a string enum type accepts only its own members, not the equal string:
enum Priority {
Low = "low",
High = "high",
}
const fromForm: Priority = "high";
example.ts(6,7): error TS2322: Type '"high"' is not assignable to type 'Priority'.
Some teams like that strictness; it also means that every caller has
to import the enum, and that data from JSON (which contains the
plain string "high") has to be converted before it can
be used.
What an enum compiles to
Almost all TypeScript disappears when it is compiled. An enum does
the opposite: it becomes code. This is the JavaScript that
TypeScript 7.0 emits for a numeric enum, a string enum and a
const enum (target ES2023, without the CommonJS header
lines):
enum TicketStatus { Open, InProgress, Resolved }
enum Priority { Low = "low", High = "high" }
const enum Unit { KWh = "kWh", M3 = "m3" }
console.log(TicketStatus.InProgress, TicketStatus[1], Priority.High, Unit.KWh);
var TicketStatus;
(function (TicketStatus) {
TicketStatus[TicketStatus["Open"] = 0] = "Open";
TicketStatus[TicketStatus["InProgress"] = 1] = "InProgress";
TicketStatus[TicketStatus["Resolved"] = 2] = "Resolved";
})(TicketStatus || (TicketStatus = {}));
var Priority;
(function (Priority) {
Priority["Low"] = "low";
Priority["High"] = "high";
})(Priority || (Priority = {}));
console.log(TicketStatus.InProgress, TicketStatus[1], Priority.High, "kWh" /* Unit.KWh */);
The numeric enum becomes a function that fills an object twice per
member: TicketStatus["Open"] = 0 and
TicketStatus[0] = "Open"; that is the reverse mapping.
The string enum is filled once per member. Run, the program prints
1 InProgress high kWh.
const enums
A const enum is an
enum whose members the compiler copies into the code wherever they
are used, without creating an object at run time. In the output
above, Unit has vanished and Unit.KWh has
become the string "kWh" with a comment. That saves a
few bytes, but it only works when the compiler can see the enum's
declaration while it compiles the file that uses it. Tools that
compile one file at a time (bundlers, Node.js type stripping) cannot
inline a const enum declared in another file. With
isolatedModules, tsc keeps such a const
enum as a normal object, and refuses one that is only declared in a
.d.ts file, as library types often are:
a.ts(2,13): error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.
Avoid const enums in code that other projects import; a union of literals gives the same zero-cost result without the restriction.
Why enums are not erasable
Most TypeScript syntax is erasable: delete the type
annotations, interfaces, type aliases and as casts, and
what remains is valid JavaScript that does exactly what the program
meant (the term
erasable syntax
is explained with the other ways to run TypeScript). That is how
Node.js
type stripping
runs .ts files without a compiler: in the words of the
Node.js documentation, it replaces the TypeScript syntax with
whitespace, so even the line and column numbers stay the same:
function cost(kWh: number, price: number): number {
return kWh * price;
}
function cost(kWh , price ) {
return kWh * price;
}
Try the same with an enum or a
parameter property
and there is nothing left to keep. Replace
enum TicketStatus { Open, InProgress, Resolved } with
spaces, and the object that the rest of the program reads
(TicketStatus.Open) no longer exists. Replace
public readonly in
constructor(public readonly id: string) with spaces,
and the field and the assignment this.id = id are gone.
The same holds for a namespace that contains code and
for import x = require(…). The old
<string>value cast is also refused by
erasableSyntaxOnly and type-stripping tools, not
because it generates code, but because it is ambiguous to parse (it
clashes with JSX); write value as string instead.
So these features are where TypeScript is more than "JavaScript plus types", and they no longer fit everywhere:
-
Node.js type stripping refuses them with the error code
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. (Node.js 22.7 added an experimental flag,--experimental-transform-types, to generate the code anyway; it was removed again in Node.js 26.0.) -
The compiler option
erasableSyntaxOnlymakestscreport them, so a project that runs its code through type stripping finds out at compile time, not when it starts:
npx tsc --ignoreConfig --erasableSyntaxOnly --noEmit tickets.ts
tickets.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled. tickets.ts(3,15): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
Line 1 of tickets.ts was
enum Status { Open, Closed }, line 3 a constructor with
public id: string. Neither feature is deprecated, and
both still work fine with tsc or a bundler. But if your
code should run with type stripping, or you simply want the output
to be your source minus the types, use the erasable alternatives in
the next section.
const assertions
Normally TypeScript infers a broad type for a value that could
change: ["DAY", "NIGHT"] becomes string[],
and { region: "NL" } has a region of type
string. A
const assertion, written
as const after a literal, tells the compiler that the
value will never change: every string or number keeps its literal
type, arrays become read-only tuples, and every property becomes
readonly.
const tariffCodes = ["DAY", "NIGHT"] as const;
tariffCodes.push("PEAK");
example.ts(2,13): error TS2339: Property 'push' does not exist on type 'readonly ["DAY", "NIGHT"]'.
const config = { region: "NL", vatRate: 0.21 } as const;
config.region = "BE";
example.ts(2,8): error TS2540: Cannot assign to 'region' because it is a read-only property.
Like everything else in the type system, this is a compile-time
promise. as const does not call
Object.freeze; JavaScript code that ignores the types
can still change the object.
An as const object instead of an enum
An object with as const gives you named constants at
run time, just like an enum, and a type can be derived from it with
two
type operators. The official handbook puts it plainly: "In modern TypeScript, you
may not need an enum when an object with as const could suffice."
const TicketStatus = {
Open: "open",
InProgress: "in-progress",
Resolved: "resolved",
} as const;
// "open" | "in-progress" | "resolved"
type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
function label(status: TicketStatus): string {
return status.toUpperCase();
}
console.log(label(TicketStatus.InProgress)); // like an enum member
console.log(label("resolved")); // a plain string works too
console.log(Object.values(TicketStatus)); // exactly three values
IN-PROGRESS RESOLVED [ 'open', 'in-progress', 'resolved' ]
The value and the type may share the name TicketStatus
because values and types live in separate namespaces. The type is a
union of string literals, so typos are still caught:
const TicketStatus = { Open: "open", Resolved: "resolved" } as const;
type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
const s: TicketStatus = "closed";
example.ts(4,7): error TS2322: Type '"closed"' is not assignable to type 'TicketStatus'.
Everything here is erasable: the object is plain JavaScript, and the type line disappears.
The satisfies operator
An annotation such as
const tariffs: Record<string, number> = … does
two things: it checks the value, and it replaces the value's own
type by the annotated one. Sometimes that second part loses
information. A record with string keys accepts any key,
so a misspelled key compiles and fails at run time:
const tariffs: Record<string, number> = { day: 0.32, night: 0.25 };
console.log(tariffs.day.toFixed(2));
console.log(tariffs.dya.toFixed(2)); // typo, but the type allows any key
0.32 TypeError: Cannot read properties of undefined (reading 'toFixed')
The
satisfies operator
(TypeScript 4.9), written value satisfies Type, checks
that a value is compatible with a type but leaves the value's
inferred type as it was. The object is still checked against
Record<string, number>, while the variable keeps
its precise type { day: number; night: number }:
const tariffs = { day: 0.32, night: 0.25 } satisfies Record<string, number>;
console.log(tariffs.dya.toFixed(2));
example.ts(3,21): error TS2339: Property 'dya' does not exist on type '{ day: number; night: number; }'.
With a union of keys, satisfies also checks that the
object is complete and has no misspelled keys, while the object
keeps its own type:
type TariffCode = "day" | "night";
const tariffs = { day: 0.32, nigth: 0.25 } satisfies Record<TariffCode, number>;
example.ts(3,30): error TS2561: Object literal may only specify known properties, but 'nigth' does not exist in type 'Record<TariffCode, number>'. Did you mean to write 'night'?
as const satisfies Type combines both: literal,
read-only types for the value, and a check against the shape. It is
a good fit for configuration tables:
type TariffCode = "day" | "night";
const tariffs = { day: 0.32, night: 0.25 } as const satisfies Record<TariffCode, number>;
const dayPrice: 0.32 = tariffs.day; // the literal type is kept
console.log(dayPrice, Object.keys(tariffs));
0.32 [ 'day', 'night' ]
When the value does not fit, the error comes from
satisfies itself:
const vatPercent = 21 satisfies string;
example.ts(1,23): error TS1360: Type 'number' does not satisfy the expected type 'string'.
Type assertions: as
A type assertion,
value as Type, tells the compiler to treat a value as a
given type, overriding what it inferred. It changes nothing at run
time and performs no check: it is a statement from you to the
compiler, not a test of the data (despite the name, it is unrelated
to the
assertions
in tests). A const assertion is the one special form
that only narrows a literal.
The compiler still refuses assertions between types that cannot possibly overlap:
const kWh = 4.2;
const label = kWh as string;
example.ts(2,15): error TS2352: Conversion of type 'number' to type 'string' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
As the message says, kWh as unknown as string would
compile, and would then be a lie. To convert a value, call
a function such as String(kWh); as never
converts.
The dangerous case is data from outside.
JSON.parse returns any, and an
as on top of that is accepted without complaint,
whatever the JSON contains:
interface Ticket {
id: string;
priority: number;
}
const ticket = JSON.parse('{"id": "INC-10234", "priority": "high"}') as Ticket;
console.log(ticket.id, ticket.priority + 1); // the type says number
INC-10234 high1
The compiler believed the assertion, so priority + 1
was allowed, and JavaScript then joined two strings. No error, just
wrong data traveling further into the program.
Older code also uses an angle-bracket form,
<Ticket>data, with the same meaning. It does not
work in .tsx files, where it would be read as JSX, and
it is not erasable; write as.
Choosing between them
| Option | At run time | Erasable | Good for |
|---|---|---|---|
| Union of string literals | nothing (only the strings themselves) | yes | most cases: parameters, fields, JSON data, API contracts |
as const object + derived type |
a plain object | yes | when you also want named constants or a list of all values at run time |
| String enum | a generated object | no | existing code bases and teams that want members to be used only by name |
| Numeric enum | a generated object with reverse mapping | no | bit flags and interop with code that stores numbers; check numbers from outside yourself |
const enum |
nothing (values inlined) | no |
code compiled in one piece by tsc; avoid in
libraries
|
Enums are not deprecated, and you will meet them in many code bases;
read them with the emitted object in mind. For new code, a union of
literals is the simplest choice, an as const
object the next step up.