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:

  1. an enum, a TypeScript feature that creates a type and an object (this page);
  2. a union of literal types, type TariffCode = "day" | "night" (explained in Union, Intersection and Literal Types);
  3. 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 erasableSyntaxOnly makes tsc report 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.