bastianplsfix

Enum patterns

Everything the enums page is for has a plain JavaScript answer that emits no extra code and survives every toolchain. There are four patterns, and the choice between them is decided by which of three jobs you have. A namespace for constants with primitive values: an object literal with as const, the common case, two lines. A custom type whose values must be distinguishable: the same object with the value type derived from it, or a union of string literals if you can live with lookalikes. A namespace for constants that carry data: an object of objects, which no enum can do at all, or a class when the values need behaviour.

The rest of this entry is the details that make each one work.

Create programs/enum-patterns.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Below the import, add the helper type and the constants the whole page shares; each earns its explanation in its step.

type ValueOf<Obj> = Obj[keyof Obj];

const Colour = {
Red: "#FF0000",
Green: "#00FF00",
} as const;

const LooseColour = {
Red: "#FF0000",
Green: "#00FF00",
};

const FrozenColour = Object.freeze({
Red: "#FF0000",
Green: "#00FF00",
});

const ProtoNullColour = {
__proto__: null,
Red: "#FF0000",
Green: "#00FF00",
} as const;

function createEnum<
T extends { [key: string]: V },
V extends string | number | boolean | symbol | object,
>(members: T): Readonly<T> {
return Object.freeze({
__proto__: null,
...members,
});
}

const Pending = Symbol("Pending");
const Ongoing = Symbol("Ongoing");

const Status = { Pending, Ongoing } as const;

const InlineStatus = {
Pending: Symbol("Pending"),
Ongoing: Symbol("Ongoing"),
} as const;

const statusPairs = [
[Pending, "not yet"],
[Ongoing, "running"],
] as const;

Follow the page as you add and revise the runnable examples below it.

an object with as const, and one helper type

Deno.test("an object with as const, and one helper type", () => {
type ColourValue = ValueOf<typeof Colour>;

const red: ColourValue = "#FF0000";

// @ts-expect-error: Type '"#0000FF"' is not assignable to type '"#FF0000" | "#00FF00"'.
const blue: ColourValue = "#0000FF";

assertStrictEquals(Colour.Red, red);
assertStrictEquals(blue, "#0000FF");
assertEquals(Object.keys(Colour), ["Red", "Green"]);
});
Check programs/enum-patterns.test.ts
running 1 test from ./programs/enum-patterns.test.ts
an object with as const, and one helper type ... ok (322µs)

ok | 1 passed | 0 failed (1ms)

An object literal for the values, as const so the property types stay literal, and ValueOf<typeof Colour> for the type. typeof Colour is the type of the object, which the classes as values page explains for classes and which works the same way here, and indexing it by keyof gives the union of what the properties hold. ValueOf is one line and not built in, so write it once per project; the generics that make it work are ground this series has not covered yet. And note Object.keys answering with exactly the member list, the question the enums page showed a number enum getting wrong.

without as const the derived type is just string

LooseColour is the same object without as const. Shield an obviously wrong assignment against it, expecting the refusal:

Deno.test("without as const the derived type is just string", () => {
// @ts-expect-error: Type '"not a colour at all"' is not assignable to type '"#FF0000" | "#00FF00"'.
const anything: ValueOf<typeof LooseColour> = "not a colour at all";

assertStrictEquals(anything, "not a colour at all");
});
Check programs/enum-patterns.test.ts
TS2578 [ERROR]: Unused '@ts-expect-error' directive.
// @ts-expect-error: Type '"not a colour at all"' is not assignable to type '"#FF0000" | "#00FF00"'.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///programs/enum-patterns.test.ts:72:3

error: Type checking failed.

The shield itself is the error, because the line it protects compiles fine: the error the directive expected never happened. Without as const the properties are typed string, so the derived type is string and accepts anything, which makes this the one part of the pattern that is not optional: no as const, no useful type. The read-only page has the three things as const does, and the literal narrowing is the one at work here. Drop the shield and let the junk assignment stand as the evidence:

Deno.test("without as const the derived type is just string", () => {
const anything: ValueOf<typeof LooseColour> = "not a colour at all";

assertStrictEquals(anything, "not a colour at all");
assertStrictEquals(LooseColour.Red, "#FF0000");
});
without as const the derived type is just string ... ok (26µs)

freezing gives the same type and a run-time guarantee

Deno.test("freezing gives the same type and a run-time guarantee", () => {
const red: ValueOf<typeof FrozenColour> = "#FF0000";

assertThrows(
() => {
// @ts-expect-error: Cannot assign to 'Red' because it is a read-only property.
FrozenColour.Red = "#0000FF";
},
TypeError,
"Cannot assign to read only property",
);

assertStrictEquals(FrozenColour.Red, red);
assert(Object.isFrozen(FrozenColour));
assertFalse(Object.isFrozen(Colour));
});
freezing gives the same type and a run-time guarantee ... ok (669µs)

Object.freeze produces the same type as as const for an object of primitives, so you do not need both, and it adds the thing as const cannot: the write throws, the type-versus-lock distinction the read-only page measured with the same TypeError. The checker refuses it and the runtime refuses it, which is the difference between a promise and a fact, and the last pin shows as const alone froze nothing. The cost is a function call and slightly more to read, worth it for a constant that many files import.

a null prototype removes members you never declared

Deno.test("a null prototype removes members you never declared", () => {
assert("toString" in Colour);
assertFalse("toString" in ProtoNullColour);

assertStrictEquals(typeof Colour.toString, "function");
assertStrictEquals(Object.getPrototypeOf(ProtoNullColour), null);

assertEquals(Object.keys(ProtoNullColour), ["Red", "Green"]);
assertEquals(Object.values(ProtoNullColour), ["#FF0000", "#00FF00"]);
});
a null prototype removes members you never declared ... ok (42µs)

An ordinary object inherits from Object.prototype, so "toString" in Colour is true and Colour.toString is a function, and neither is a member of your enum. The __proto__: null key in the literal removes the prototype, and the questions start answering correctly. Object.keys and Object.values were always fine, because they only see own properties, so this improvement matters exactly when you use in or read a property by a computed name, the same case the objects as dictionaries page makes for lookup tables generally, along with the Deno detail that matters here: the __proto__ accessor is a stub, while the __proto__: key in a literal still works, which is why this spelling is the way to a null prototype.

and puts a member into the type that is not there

The type of ProtoNullColour carries a readonly __proto__: null member. Predict whether the object does:

Deno.test("and puts a member into the type that is not there", () => {
const nothing: ValueOf<typeof ProtoNullColour> = null;

assertStrictEquals(nothing, null);
assertStrictEquals("__proto__" in ProtoNullColour, true);
});
Check programs/enum-patterns.test.ts
running 5 tests from ./programs/enum-patterns.test.ts
...
and puts a member into the type that is not there ... FAILED (8ms)

ERRORS

and puts a member into the type that is not there => ./programs/enum-patterns.test.ts:109:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- false
+ true

FAILURES

and puts a member into the type that is not there => ./programs/enum-patterns.test.ts:109:6

FAILED | 4 passed | 1 failed (10ms)

error: Test failed

No such property. Here is the catch, and it is annoying: TypeScript treats __proto__ in the literal as a property, so the type carries readonly __proto__: null and the derived value union gains null, which is why the first line compiled, while at run time the key configured the prototype and left nothing behind. So the type is wrong about the object in the one place you were trying to be careful. Correct the prediction and pin the fix, an Omit that has to be written at every derivation:

Deno.test("and puts a member into the type that is not there", () => {
const nothing: ValueOf<typeof ProtoNullColour> = null;

// @ts-expect-error: Type 'null' is not assignable to type '"#FF0000" | "#00FF00"'.
const alsoNothing: ValueOf<Omit<typeof ProtoNullColour, "__proto__">> = null;

assertStrictEquals(nothing, alsoNothing);
assertFalse("__proto__" in ProtoNullColour);
});
and puts a member into the type that is not there ... ok (30µs)

That per-derivation friction is enough to want a helper.

one helper does all three at once

Deno.test("one helper does all three at once", () => {
const Tree = createEnum({
Maple: "MAPLE",
Oak: "OAK",
});

const maple: ValueOf<typeof Tree> = "MAPLE";

// @ts-expect-error: Type '"BIRCH"' is not assignable to type '"MAPLE" | "OAK"'.
const birch: ValueOf<typeof Tree> = "BIRCH";

assertStrictEquals(Tree.Maple, maple);
assertStrictEquals(birch, "BIRCH");
assertStrictEquals(Object.getPrototypeOf(Tree), null);
assert(Object.isFrozen(Tree));
assertEquals(Object.keys(Tree), ["Maple", "Oak"]);
});
one helper does all three at once ... ok (46µs)

Frozen, null-prototyped, literal-typed, and no __proto__ in the type, because the return type is Readonly<T> and T came from the argument; no as const at the call site either. Two details worth noticing in createEnum itself. The two type parameters are what keep the property types literal: T extends {[key: string]: V} with V constrained to the primitives makes TypeScript infer literal types rather than widening them. And the function body needs no cast, because __proto__ in a literal is not treated as an excess property, so Object.freeze({__proto__: null, ...members}) is assignable to Readonly<T> as written.

symbols are unique, and only if you declare them outside

Deno.test("symbols are unique, and only if you declare them outside", () => {
const pending: ValueOf<typeof Status> = Pending;

// @ts-expect-error: Type 'symbol' is not assignable to type 'typeof Pending | typeof Ongoing'.
const lookalike: ValueOf<typeof Status> = Symbol("Pending");

const anySymbol: ValueOf<typeof InlineStatus> = Symbol("whatever");

assertStrictEquals(pending, Status.Pending);
assertFalse(lookalike === Pending);
assertFalse(anySymbol === InlineStatus.Pending);
});
symbols are unique, and only if you declare them outside ... ok (18µs)

A string value can be forged, since anybody can type "Pending". A symbol cannot, because the only way to get one is to import the constant, the argument the symbols page makes. The catch is in the unshielded anySymbol line: the symbols have to be declared outside the object literal, because written inline, Symbol("Pending") in an as const object comes out as plain symbol, the derived union collapses to symbol, and the pattern stops protecting anything. So the symbol version costs two extra declarations per member, which is exactly the verbosity that makes people reach for strings.

and symbols cost you the inference on a Map

Deno.test("and symbols cost you the inference on a Map", () => {
// @ts-expect-error: No overload matches this call.
const inferred = new Map(statusPairs);

const explicit = new Map<ValueOf<typeof Status>, string>(statusPairs);

const strings = new Map([["Pending", "not yet"]] as const);
const value: "not yet" | undefined = strings.get("Pending");

assertStrictEquals(inferred.size, 2);
assertStrictEquals(explicit.get(Pending), "not yet");
assertStrictEquals(value, "not yet");
});
and symbols cost you the inference on a Map ... ok (42µs)

Building a Map from an array of symbol-keyed pairs cannot be inferred, so the type arguments have to be written out, while the shielded version still works at run time, as inferred.size shows. The all-string version infers both, down to the literal type of the values, so strings.get("Pending") is "not yet" | undefined. That is a second tax on the same choice: symbols buy uniqueness and charge you in declarations and in inference, worth paying when two enums of the same shape must never be confused, and not worth paying for a list of colours.

values that carry data, and an exhaustiveness check

Deno.test("values that carry data, and an exhaustiveness check", () => {
type TextStyleShape = { key: string; html: string; latex: string };

const TextStyle = {
Bold: { key: "Bold", html: "b", latex: "textbf" },
Italics: { key: "Italics", html: "i", latex: "textit" },
} as const satisfies Record<string, TextStyleShape>;

type TextStyleValue = ValueOf<typeof TextStyle>;

function describeStyle(style: TextStyleValue): string {
switch (style.key) {
case TextStyle.Bold.key:
return "bold text";
case TextStyle.Italics.key:
return "text in italics";
default: {
const exhaustive: never = style;
return exhaustive;
}
}
}

assertStrictEquals(describeStyle(TextStyle.Bold), "bold text");
assertStrictEquals(describeStyle(TextStyle.Italics), "text in italics");
assertStrictEquals(TextStyle.Bold.latex, "textbf");
});
values that carry data, and an exhaustiveness check ... ok (28µs)

This is the job an enum cannot do: a lookup table whose values are objects, with three things working together. as const satisfies Record<string, TextStyleShape> checks every value against a shape without replacing the object's own type, the pairing the object types page named as the spelling for tables; get a property name wrong and the error is here rather than at a use site. Each value carries a key with a distinct literal type, so TextStyleValue is a discriminated union, not by design of the pattern but just by having a distinguishing property. And that makes the never assignment work, so adding a member and forgetting a case is a compile error, the exhaustiveness check from the unions and narrowing page arriving in a pattern that started as a plain object.

a class, when the values need behaviour

Deno.test("a class, when the values need behaviour", () => {
class Style {
static readonly Bold = new Style("b");
static readonly Italics = new Style("i");

private constructor(readonly html: string) {}

wrap(text: string): string {
return `<${this.html}>${text}</${this.html}>`;
}
}

type StyleKey = Exclude<keyof typeof Style, "prototype">;

assertStrictEquals(Style.Bold.wrap("Hello"), "<b>Hello</b>");

const key: keyof typeof Style = "prototype";

// @ts-expect-error: Type '"prototype"' is not assignable to type '"Bold" | "Italics"'.
const better: StyleKey = "prototype";

assertStrictEquals(key, better);
assertEquals(Object.keys(Style), ["Bold", "Italics"]);
});
a class, when the values need behaviour ... ok (45µs)

The class holds its own instances as static members, and the private constructor means those are the only ones, the pattern the typing classes page covers in its own right. The values get methods, which is the only thing this pattern has that the object of objects does not. Two details: keyof typeof Style includes "prototype", so deriving the member names needs the Exclude, while Object.keys skips it because prototype is not enumerable. The real downside is that every static has the type Style, so there is no discriminated union and therefore no exhaustiveness check. Choose the object of objects when you want the compiler to find every switch you forgot, and the class when the values need behaviour more than you need that.

a string literal union needs reifying before you can iterate

Deno.test("a string literal union needs reifying before you can iterate", () => {
type Activation = "Active" | "Inactive";

const ACTIVATIONS = new Set(["Active", "Inactive"] as const);
const ACTIVATION_LIST = ["Active", "Inactive"] as const;

type FromList = (typeof ACTIVATION_LIST)[number];

const active: Activation = "Active";
const alsoActive: FromList = active;

// @ts-expect-error: Argument of type '"Cancelled"' is not assignable to parameter of type '"Active" | "Inactive"'.
assertFalse(ACTIVATIONS.has("Cancelled"));

assert(ACTIVATIONS.has(alsoActive));
assertEquals([...ACTIVATIONS], ["Active", "Inactive"]);
assertEquals(ACTIVATION_LIST.map((name) => name.length), [6, 8]);
});
a string literal union needs reifying before you can iterate ... ok (32µs)

A union of string literals is the lightest pattern of the four: one line, no values to import, exhaustiveness checks included, and it reads well in an error message. What it does not have is a run-time existence, so the moment you want to iterate over the members, render them as options, or check whether an incoming string is one of them, you need a value. Building the value first and deriving the type from it is the way round that: new Set([...] as const) gives a membership test that even type-checks its argument, and (typeof list)[number] does the same job with an array when order matters. The direction matters: derive the type from the value, never maintain both by hand.

crossing the boundary in both directions

Deno.test("crossing the boundary in both directions", () => {
function parseEnumKey<E extends Record<string, unknown>>(
members: E,
key: string,
): E[keyof E] {
if (!Object.hasOwn(members, key)) {
throw new TypeError(`Unknown key: ${key}`);
}
return members[key] as E[keyof E];
}

function stringifyEnumValue<E extends object>(
members: E,
value: E[keyof E],
): keyof E {
for (const [key, candidate] of Object.entries(members)) {
if (candidate === value) return key as keyof E;
}
throw new TypeError(`Unknown value: ${String(value)}`);
}

assertStrictEquals(parseEnumKey(Status, "Ongoing"), Status.Ongoing);
assertStrictEquals(stringifyEnumValue(Status, Status.Ongoing), "Ongoing");

assertThrows(
() => parseEnumKey(Status, "Cancelled"),
TypeError,
"Unknown key: Cancelled",
);
});
crossing the boundary in both directions ... ok (90µs)

Symbols and objects do not survive JSON.stringify, so an enum of either needs these two functions at the edges: a name arrives from outside and becomes a value, and a value goes outside as a name. Object.hasOwn rather than a truthiness check, for the reason the objects as dictionaries page gives, and a throw rather than undefined, because an unknown key is bad data rather than a missing entry. Both need one assertion each, as E[keyof E] and as keyof E, because the relationship between a key and its value is beyond what the checker can follow here; they are the honest kind, checked at run time on the line above.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/branding.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes-as-values.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/enum-patterns.test.ts
Check programs/enums.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/function-types.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/overloading.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/typing-arrays.test.ts
Check programs/typing-classes.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 9 tests from ./programs/branding.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 10 tests from ./programs/classes-as-values.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 12 tests from ./programs/enum-patterns.test.ts
an object with as const, and one helper type ... ok (283µs)
without as const the derived type is just string ... ok (17µs)
freezing gives the same type and a run-time guarantee ... ok (278µs)
a null prototype removes members you never declared ... ok (40µs)
and puts a member into the type that is not there ... ok (23µs)
one helper does all three at once ... ok (41µs)
symbols are unique, and only if you declare them outside ... ok (14µs)
and symbols cost you the inference on a Map ... ok (19µs)
values that carry data, and an exhaustiveness check ... ok (30µs)
a class, when the values need behaviour ... ok (50µs)
a string literal union needs reifying before you can iterate ... ok (41µs)
crossing the boundary in both directions ... ok (81µs)
running 7 tests from ./programs/enums.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 12 tests from ./programs/function-types.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 8 tests from ./programs/overloading.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 10 tests from ./programs/typing-arrays.test.ts
...
running 12 tests from ./programs/typing-classes.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 730 passed | 0 failed (1s)

Twelve tests, and the practice is short. Reach for an object literal with as const, which covers the first two jobs in two lines with ValueOf<typeof Obj> for the type, adding Object.freeze when the constant is shared widely and a createEnum helper if you write all three improvements more than twice. Use a string literal union when you want the lightest thing that works, reifying it with a Set the moment you need to iterate or validate, and derive the type from the value. Use symbols when two things must stay distinguishable even though they are spelled the same, knowing the price: declare each symbol outside the object, write the type arguments on any Map keyed by them, and write the two boundary functions for serialisation. Use an object of objects for constants that carry data, with as const satisfies Record<string, Shape> and a key in each value, which buys an exhaustiveness check for free. Use a class only when the values need methods, giving up the discriminated union, which is a real loss. And do not maintain a type and a value that have to agree, because every pattern here derives one from the other, and the one that does not is the one that goes stale.