Nominal types and branding
TypeScript compares types by structure. Two declarations with the same members describe the same set of values, whatever you called them, so a Person satisfies a Color if both have a name: string. Naming is not a check.
Branding is the technique for opting out: add a member that no other type can produce. For a class, that member is a # private field. For anything else, including primitives, it is an intersection with a property whose key is a unique symbol. Both forms are compile-time only, so a branded value is exactly the value it was, with no wrapper and no cost, and nothing about it is protected once the program runs.
Create programs/branding.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 two branded classes and the branded-primitive machinery the page builds up to, so the later steps can share them:
class BrandedColor {
#brand = true;
constructor(readonly name: string) {}
static isBranded(value: object): boolean {
return #brand in value;
}
}
class BrandedPerson {
#brand = true;
constructor(readonly name: string) {}
static isBranded(value: object): boolean {
return #brand in value;
}
}
declare const brand: unique symbol;
type Branded<T, Name extends string> = T & { readonly [brand]: Name };
type UserId = Branded<string, "UserId">;
type OrderId = Branded<string, "OrderId">;
type Meters = Branded<number, "Meters">;
type Duration = Branded<number, "Duration">;
function toUserId(raw: string): UserId {
if (!/^u\d+$/.test(raw)) throw new TypeError(`not a user id: ${raw}`);
return raw as UserId;
}
function loadUser(id: UserId): string {
return `loading ${id}`;
}
The steps below explain every piece of that block. Follow the page as you add and revise the runnable examples below it.
naming is not a check
Two classes with nothing in common but a shape, and a plain literal. Predict the instanceof:
Deno.test("naming is not a check", () => {
class Color {
constructor(readonly name: string) {}
}
class Person {
constructor(readonly name: string) {}
}
const robin = new Person("Robin");
const green = new Color("green");
const asColor: Color = robin;
const asPerson: Person = green;
const fromLiteral: Person = { name: "Robin" };
assertStrictEquals(asColor.name, "Robin");
assertStrictEquals(asPerson.name, "green");
assertStrictEquals(fromLiteral.name, "Robin");
assertStrictEquals(fromLiteral instanceof Person, true);
});
Check programs/branding.test.ts
running 1 test from ./programs/branding.test.ts
naming is not a check ... FAILED (8ms)
ERRORS
naming is not a check => ./programs/branding.test.ts:47:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- false
+ true
FAILURES
naming is not a check => ./programs/branding.test.ts:47:6
FAILED | 0 passed | 1 failed (9ms)
error: Test failed
Three assignments that look wrong and are all correct, and one instanceof that a Person-typed value fails. The two classes describe the same shape, so they describe the same type, and a plain object with a name describes it too; only the run-time question noticed anything, because the type never conferred instance-hood. Correct the prediction to false:
naming is not a check ... ok (206µs)
one private field makes a class nominal
Deno.test("one private field makes a class nominal", () => {
const robin = new BrandedPerson("Robin");
// @ts-expect-error: Type 'BrandedPerson' is not assignable to type 'BrandedColor'.
const asColor: BrandedColor = robin;
// @ts-expect-error: Property '#brand' is missing in type '{ name: string; }' but required in type 'BrandedPerson'.
const fromLiteral: BrandedPerson = { name: "Robin" };
assertStrictEquals(asColor.name, "Robin");
assertStrictEquals(fromLiteral.name, "Robin");
});
one private field makes a class nominal ... ok (31µs)
BrandedPerson and BrandedColor declare the same field with the same name and the same type, and the two classes are incompatible. Both refusals are what you wanted, and both run fine when shielded, because none of this exists at run time. The checker's message for the class-to-class case is worth capturing on its own. Put it in a scratch file programs/nominal.ts:
class Color {
#brand = true;
constructor(readonly name: string) {}
}
class Person {
#brand = true;
constructor(readonly name: string) {}
}
const robin = new Person("Robin");
export const asColor: Color = robin;
Check programs/nominal.ts
TS2322 [ERROR]: Type 'Person' is not assignable to type 'Color'.
Property '#brand' in type 'Person' refers to a different member that cannot be accessed from within type 'Color'.
export const asColor: Color = robin;
~~~~~~~
at file:///programs/nominal.ts:15:14
error: Type checking failed.
"A different member that cannot be accessed" is a compile-time restatement of a run-time rule: a private name is scoped to the class body that declares it, as the private class members page establishes, so the two #brands are unrelated members that happen to be spelled alike. That is why this form of branding is stable. It is not a trick played on the checker; it is the checker reporting a fact about JavaScript. Delete the scratch file.
the same private name in two classes is two members
Deno.test("the same private name in two classes is two members", () => {
const robin = new BrandedPerson("Robin");
const green = new BrandedColor("green");
assert(BrandedPerson.isBranded(robin));
assertFalse(BrandedPerson.isBranded(green));
assert(BrandedColor.isBranded(green));
assertFalse(BrandedColor.isBranded(robin));
});
the same private name in two classes is two members ... ok (35µs)
The run-time half of the same fact. #brand in value inside one class body cannot see the other's field, so each isBranded recognises only its own instances, which is the brand check the private class members page introduced, now doing nominal work.
an intersection brands anything, primitives included
Deno.test("an intersection brands anything, primitives included", () => {
const id = toUserId("u1");
assertStrictEquals(loadUser(id), "loading u1");
// @ts-expect-error: Argument of type 'string' is not assignable to parameter of type 'UserId'.
loadUser("u1");
const order = "o1" as OrderId;
// @ts-expect-error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
loadUser(order);
assertThrows(() => toUserId("nope"), TypeError, "not a user id");
});
an intersection brands anything, primitives included ... ok (276µs)
UserId is string intersected with an object type carrying one property, and that property's key is a symbol nobody else has. So no string is a UserId, no OrderId is a UserId, and the only way to get one is toUserId, which is the point: there is now exactly one place in the program where a raw string becomes an identifier, and that place can validate. declare const brand: unique symbol declares a symbol that never exists, emitting nothing and allocating nothing, with a key type unique to that declaration; the symbols page has what unique symbol means and why a symbol can be a type rather than just a value.
a branded primitive is still a primitive
Deno.test("a branded primitive is still a primitive", () => {
const id = toUserId("u1");
assertStrictEquals(typeof id, "string");
assertStrictEquals(id.toUpperCase(), "U1");
assertStrictEquals(id.length, 2);
assertStrictEquals(`id=${id}`, "id=u1");
const asString: string = id;
assertStrictEquals(asString, "u1");
});
a branded primitive is still a primitive ... ok (58µs)
No wrapper object, no allocation, no method to unwrap. typeof answers "string", every string method works, and the value flows into anything that wants a string, because an intersection is assignable to each of its parts. The asymmetry is the whole feature: a UserId is a string, and a string is not a UserId.
arithmetic strips the brand
Deno.test("arithmetic strips the brand", () => {
const width = 3 as Meters;
const height = 4 as Meters;
const sum = width + height;
assertStrictEquals(sum, 7);
// @ts-expect-error: Type 'number' is not assignable to type 'Meters'.
const stillMeters: Meters = sum;
assertStrictEquals(stillMeters, 7);
});
arithmetic strips the brand ... ok (21µs)
+ on two Meters gives a number, so the brand is gone and putting it back takes another cast. This is the practical limit of branding numbers, worth knowing before you brand a unit of measure and expect the arithmetic to carry it; the workaround is arithmetic that keeps the brand, a function per operation, and it is a real cost. Brand a number when the value is an identifier or a checked quantity that gets passed around rather than computed with, and brand a string more freely, since string operations are rarer in the same code.
a string brand key is forgeable and a symbol key is not
Deno.test("a string brand key is forgeable and a symbol key is not", () => {
type ValidatedBySymbol = { text: string } & { readonly [brand]: "Validated" };
type ValidatedByString = { text: string } & { readonly __brand: "Validated" };
const forged: ValidatedByString = { text: "x", __brand: "Validated" };
// @ts-expect-error: Property '[brand]' is missing in type '{ text: string; }' but required in type 'ValidatedBySymbol'.
const cannotForge: ValidatedBySymbol = { text: "x" };
assertStrictEquals(forged.text, "x");
assertStrictEquals(cannotForge.text, "x");
});
a string brand key is forgeable and a symbol key is not ... ok (23µs)
The __brand: "Validated" version can be written out by anybody, with no cast, because the key is an ordinary string and the value is an ordinary literal, so the brand is a convention, and a convention that an object literal can satisfy is not a brand at all. With a unique symbol key there is nothing to type: the property cannot be written because the key cannot be named, so the only route in is an assertion, which is exactly the choke point you were trying to create. You will see the string form in the wild, often with a comment saying it is phantom. It is the weaker version, and there is no reason to prefer it.
a brand does not survive a copy, and the type will not say so
cloned is typed BrandedPerson, by structuredClone's own signature. Predict what isBranded says about it:
Deno.test("a brand does not survive a copy, and the type will not say so", () => {
const robin = new BrandedPerson("Robin");
const cloned: BrandedPerson = structuredClone(robin);
assertStrictEquals(BrandedPerson.isBranded(cloned), true);
});
Check programs/branding.test.ts
running 8 tests from ./programs/branding.test.ts
...
a brand does not survive a copy, and the type will not say so ... FAILED (7ms)
ERRORS
a brand does not survive a copy, and the type will not say so => ./programs/branding.test.ts:151:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- false
+ true
FAILURES
a brand does not survive a copy, and the type will not say so => ./programs/branding.test.ts:151:6
FAILED | 7 passed | 1 failed (9ms)
error: Test failed
Typed BrandedPerson, and not one. The private field is gone, the prototype is gone, and the checker has no way to know, because structuredClone's signature is a promise it cannot keep for class instances, the fact the typing classes page measured from the other direction and the designing error types page measured for Error subclasses. Here the lesson is narrower: a # brand is a claim about provenance, and a copy has different provenance while carrying the same type. Branded primitives have no such problem, since there is nothing to lose. Correct the prediction and pin both halves:
Deno.test("a brand does not survive a copy, and the type will not say so", () => {
const robin = new BrandedPerson("Robin");
const cloned: BrandedPerson = structuredClone(robin);
assert(BrandedPerson.isBranded(robin));
assertFalse(BrandedPerson.isBranded(cloned));
assertFalse(cloned instanceof BrandedPerson);
assertEquals(Object.keys(cloned), ["name"]);
const id = toUserId("u1");
const text = JSON.stringify({ id });
assertStrictEquals(text, '{"id":"u1"}');
const parsed = JSON.parse(text) as { id: string };
assertStrictEquals(typeof parsed.id, "string");
assertStrictEquals(toUserId(parsed.id), id);
});
a brand does not survive a copy, and the type will not say so ... ok (276µs)
The branded string serialises as the string it always was. Coming back, it is a string and needs re-branding, which is one call at the boundary, and the same call that validates.
when structural typing is the feature
This entry is about a workaround, so it owes the other side. Structural typing is why a type can be written after the value it describes, which is where the object types page starts, and why TypeScript could be added to an existing JavaScript codebase at all. It is why a plain object works wherever a class instance is expected, which makes test fixtures trivial, and why two libraries that have never heard of each other can exchange values that happen to match. Branding gives up all of that for one type. So brand the few types where confusion is expensive, and leave the rest structural, because a codebase where everything is branded has bought nominal typing at the price of every convenience the type system had. No test on this step, since its claim is every unshielded assignment on the rest of this site.
brand at the boundary, and nowhere else
Deno.test("brand at the boundary, and nowhere else", () => {
function parseConfig(text: string): { user: UserId; timeout: Duration } {
const raw = JSON.parse(text) as Record<string, unknown>;
if (typeof raw.user !== "string" || typeof raw.timeout !== "number") {
throw new TypeError("config needs a user and a timeout");
}
return {
user: toUserId(raw.user),
timeout: raw.timeout as Duration,
};
}
const config = parseConfig('{"user":"u1","timeout":30}');
assertStrictEquals(loadUser(config.user), "loading u1");
assertStrictEquals(config.timeout, 30);
assertThrows(
() => parseConfig('{"user":"nope","timeout":30}'),
TypeError,
"not a user id",
);
});
brand at the boundary, and nowhere else ... ok (55µs)
One function does the unchecked casts, and it validates while it is there, the parse-into-unknown shape from the json page with brands applied on the way out. Every line downstream gets a type that means something, and the audit for is-this-really-a-user-id is one function long. Note the difference between the two fields: toUserId checks and as Duration does not, so a reviewer can see which claim is earned.
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.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/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
naming is not a check ... ok (195µs)
one private field makes a class nominal ... ok (23µs)
the same private name in two classes is two members ... ok (28µs)
an intersection brands anything, primitives included ... ok (288µs)
a branded primitive is still a primitive ... ok (40µs)
arithmetic strips the brand ... ok (16µs)
a string brand key is forgeable and a symbol key is not ... ok (14µs)
a brand does not survive a copy, and the type will not say so ... ok (253µs)
brand at the boundary, and nowhere else ... ok (51µs)
running 12 tests from ./programs/buffers-and-views.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 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 | 701 passed | 0 failed (1s)
Nine tests, and the practice is short. Brand identifiers, units, and validated values, the types where two values of different brands look identical, mean different things, and mixing them is a bug no other check will find. Brand at the boundary and nowhere else. Use # when the thing is already a class, and an intersection otherwise, because the class form needs no helper type and produces a better error message. Do not brand a type whose values callers construct freely, since once a type is branded every caller needs your constructor, and if you were not going to give them one, you have just made the type unusable from outside. And remember there is no run-time protection: a cast produces a branded value without any check, a copy loses a # brand silently, and a branded primitive is a primitive. Branding stops a mistake at the keyboard, which is where most of them happen, and stops nothing at all afterwards.