01 / The idea
Two well-named strings are a fair start.
You’re building an enrollment preview. A user ID comes from one field and a course ID from another, both as text, and a function builds the request. Strings are how the IDs arrive, and clear names say which is which.
Read the aliasesTypeScript · the version this lesson starts from
type PlainUserId = string;
type PlainCourseId = string;
export function enrollStrings(user: PlainUserId, course: PlainCourseId): string {
return `user=${user} | course=${course}`;
}
export function aliasExample(): string {
const user: PlainUserId = '42';
const course: PlainCourseId = '73';
return enrollStrings(course, user); // Compiles: both aliases mean string.
} Go’s first version uses aliases too: type PlainUserID = string. Both
languages meet again at the distinct types in section 02.
Then the arguments get passed the wrong way round, three helpers away from where the fields
were read. PlainUserId and PlainCourseId are both just string, so the call compiles and the request says user 73. The names helped the
reader. The compiler never saw them.
A branded type gives a primitive a compile-time label, so two values with the same representation become different types. When the only way to get one is through a function you control, it’s also opaque: callers can use the value, but can’t make one up.
If you write UI code, your router hands you courseId and userId as strings, and your API client takes them back in some order. Zod’s .brand() exists for exactly this, and its docs are clear about the limit:
“Branded types do not affect the runtime result of .parse. It is a static-only
construct.” Section 05 brands a roster’s IDs where the JSON arrives.
02 / See the shape
Give each kind a type and one way in.
The basic form is two ID types and a parser for each. In the wild is the function that asks for both kinds, and the boundary that parses each field as the kind it should hold. At the call site runs the three cases the rest of the lesson follows.
Both languages print the same lines for the same 18 shared cases. Their guarantees differ, and the reading notes say how.
Two ID kinds and one way into each. The parsers check the text first, and only then give it its kind.
declare const idKind: unique symbol;
export type UserId = string & { readonly [idKind]: 'UserId' };
export type CourseId = string & { readonly [idKind]: 'CourseId' };
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function validIdText(text: string): boolean {
return text.length >= 1 && text.length <= 6 && text[0] !== '0' && !/[^0-9]/.test(text);
}
export function parseUserId(text: string): Result<UserId> {
if (!validIdText(text)) {
return { ok: false, error: 'UserId: Use 1–6 ASCII digits, starting with 1–9.' };
}
// The only way to a UserId: the assertion follows the check.
return { ok: true, value: text as UserId };
}
export function parseCourseId(text: string): Result<CourseId> {
if (!validIdText(text)) {
return { ok: false, error: 'CourseId: Use 1–6 ASCII digits, starting with 1–9.' };
}
return { ok: true, value: text as CourseId };
} // Defined types, not aliases: adding = would make them plain strings again.
type UserID string
type CourseID string
func validIDText(text string) bool {
if len(text) < 1 || len(text) > 6 || text[0] == '0' {
return false
}
for _, digit := range []byte(text) {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func ParseUserID(text string) (UserID, error) {
if !validIDText(text) {
return "", fmt.Errorf("UserId: Use 1–6 ASCII digits, starting with 1–9.")
}
return UserID(text), nil
}
func ParseCourseID(text string) (CourseID, error) {
if !validIDText(text) {
return "", fmt.Errorf("CourseId: Use 1–6 ASCII digits, starting with 1–9.")
}
return CourseID(text), nil
} Reading the TypeScriptAn intersection, a unique symbol, and erasure
UserId is string intersected with an object type whose key is
a unique symbol. The two kinds have different labels under that key, so neither is assignable to the
other, and a plain string has no label at all.
None of it exists when the code runs. typeof an ID is still "string", and JSON contains plain text. The as UserId in the parser
follows the check; it doesn’t perform one.
Reading the GoDefined types: distinct, not opaque
type UserID string is a defined type. With an equals sign
it would be an alias and the protection would disappear; the Go tests prove both.
Defined types are distinct but not opaque. UserID(course) converts on
request, an untyped constant like "42" is accepted where a UserID is expected, and the zero value is an empty string. A string variable is
refused. A Go API that needs IDs set only by a constructor uses a struct with an
unexported field, as Parse, don’t validate does for
its Page. Other packages can’t set that field, but they can still write the
zero value, so the API has to check for it.
03 / Follow the swap
Watch the same swap compile, fail, then slip past.
Five steps, each checked against the code you just read. The TypeScript and Go messages are the real ones, pinned by the lesson’s tests. Before each step, guess whether the call compiles.
In Try it, type your own fields and choose how they reach enroll.
One swap, three outcomes.
FIELDSuserText “42”courseText “73”
enrollStrings(courseText, userText) User field “42”, course field “73”. With aliases, enrollStrings(courseText, userText) compiles and sends user=73 | course=42.
Aliases let the swap through.
PlainUserId and PlainCourseId are both string, so enrollStrings(course, user) compiles and sends user=73.
Reduced motion: choose a scene to see its completed state.
Read this scene
PlainUserId and PlainCourseId are both string, so enrollStrings(course, user) compiles and sends user=73.
User field “42”, course field “73”. With aliases, enrollStrings(courseText, userText) compiles and sends user=73 | course=42.
Watch restarts when you return. Step through keeps your selected step. Try it starts from the first fields each time you open it.
What distinct types buy you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Swaps don’t compile
enroll(course, user)is an error in both languages, with the messages from step 2. With aliases it sent user 73.- The signature says which ID
enroll(user: UserId, course: CourseId)documents the roles in a way the compiler reads, not just the names.- One way in per kind
parseUserIdis the only ordinary way to get aUserId. A search foras UserIdfinds every exception.- Same text, still separate
- User 42 and course 42 can both exist, and neither can stand in for the other.
- Reordering finds every caller
- Swap
enroll’s parameters and every typed call site stops compiling. With strings, every caller would quietly send the IDs the wrong way round.
The review word for the idea is nominal typing. TypeScript compares types by shape, and Zod’s docs describe brands as a way to “simulate nominal typing in TypeScript’s structural type system.” Step 5 already showed a limit; section 08 has the rest.
04 / Try a decision
A helper that relabels undoes the check.
Tests needed IDs quickly, so someone added asUserId(text) and asCourseId(text). Months later an admin table’s enroll button uses them, with
the data attributes copied from the unenroll handler.
05 / Give it a real job
Brand IDs where they arrive. Send plain strings out.
In the real app, IDs arrive in three places: route parameters, API responses, and database
rows. Each of those is a boundary, and each parses its IDs into their kinds. Past that
point, services and components take UserId and CourseId. When an ID leaves, in a URL or a JSON body, it goes back to being
text, and the receiving side parses it again.
Give IDs their kind
Route params, API responses, database rows.
Take the kinds they need
enroll, removeStudent, the roster view.
Carries plain text
URLs and JSON; the other side parses again.
A brand doesn’t check that user 42 exists, or that the person asking may enroll them. Those checks need data and happen on the server.
Build UIs?Every ID your router or API hands you is a string, and one day a roster with two course IDs in one action makes you decide where the brand stops.
Where it already is in your components
Your API client has functions like removeStudent(courseId, userId), and your
components call them from click handlers. With strings, a handler that passes them the
wrong way round compiles, and the bug shows up as a 404 or, worse, a change to the wrong
record. With branded IDs in the signature, it doesn’t compile.
The type only helps if the component received branded values in the first place. They come from wherever the data entered the app.
When you have to own it
Now it’s a course roster. The course ID comes from the route as a string. The students
come from an API as JSON, with userId as text or a number. So the page parses
the route parameter, and decodeRoster brands every ID in the response before any
component sees it.
Then there’s the “Move to another course” menu. Moving a student needs a from course and a to course: two CourseIds. A brand
can’t tell those apart, so the action takes named fields instead. That’s the lesson’s
limit, met in a real screen.
import {
parseCourseId,
parseUserId,
type CourseId,
type Result,
type UserId
} from '../enrollment/ids';
export type Student = Readonly<{ user: UserId; name: string }>;
export type Roster = Readonly<{ course: CourseId; students: readonly Student[] }>;
// JSON from the API is plain strings. Give each ID its kind here, once, where the
// response arrives, so nothing past this function handles an unbranded ID.
export function decodeRoster(json: unknown): Result<Roster> {
if (typeof json !== 'object' || json === null) {
return { ok: false, error: 'Roster: expected an object.' };
}
const body = json as { courseId?: unknown; students?: unknown };
const course = parseCourseId(String(body.courseId ?? ''));
if (!course.ok) return course;
if (!Array.isArray(body.students)) {
return { ok: false, error: 'Roster: expected a students list.' };
}
const students: Student[] = [];
for (const raw of body.students) {
const item = (typeof raw === 'object' && raw !== null ? raw : {}) as {
userId?: unknown;
name?: unknown;
};
const user = parseUserId(String(item.userId ?? ''));
if (!user.ok) return user;
if (typeof item.name !== 'string') {
return { ok: false, error: 'Roster: every student needs a name.' };
}
students.push(Object.freeze({ user: user.value, name: item.name }));
}
return { ok: true, value: Object.freeze({ course: course.value, students }) };
}
// Two CourseIds in one call are the same kind, so a brand can't catch them swapped.
// Named fields make each role visible at the call site instead.
export function movePath(move: { user: UserId; from: CourseId; to: CourseId }): string {
return `/api/courses/${move.from}/students/${move.user}/move?to=${move.to}`;
}
A student row whose Remove button calls removeStudent(course, user). With branded IDs, passing them the wrong way round doesn’t compile, in React or Svelte.
import type { CourseId, UserId } from '../enrollment/ids';
type Student = { user: UserId; name: string };
// The version most API clients start with:
// async function removeStudent(courseId: string, userId: string)
// A click handler that passes them the wrong way round still compiles.
async function removeStudent(course: CourseId, user: UserId): Promise<void> {
await fetch(`/api/courses/${course}/students/${user}`, { method: 'DELETE' });
}
export function StudentRow({ course, student }: { course: CourseId; student: Student }) {
return (
<li>
{student.name}{' '}
{/* removeStudent(student.user, course) won't compile: a UserId isn't a CourseId. */}
<button type="button" onClick={() => removeStudent(course, student.user)}>
Remove
</button>
</li>
);
}
06 / Recognize it elsewhere
Anywhere two values share a shape and must never trade places.
IDs are the common case. They aren’t the only one.
| Where you’ve seen it | What looks alike | What a mix-up costs |
|---|---|---|
| An API client | getMember(orgId, userId) | Another organization’s member, or a confusing 404. |
| Money | amounts in cents and in dollars | A charge a hundred times too large. |
| Durations | milliseconds and seconds | A timeout a thousand times too short. |
| A cache key | ['member', orgId, userId] | One member’s data cached under another’s key. |
The review name for the habit this fixes is primitive obsession: using a bare string or number where the domain has a more specific idea.
07 / Already in your toolbox
Your tools already keep look-alike values apart.
Three places to look. For each one, find the representation and the kind.
Zod · .brand()
Adds a brand to a schema’s inferred type, so parsed data can’t be assigned to a different
brand. The docs say it plainly: brands are static-only and don’t change what .parse returns.
Go · time.Duration
type Duration int64: a defined type in the standard library, so a count of
nanoseconds isn’t an ordinary int64. It shows the same gap as section 02: time.Sleep(5) compiles, because 5 is an untyped constant, and sleeps
five nanoseconds.
TypeScript · unique symbol
The brand key in ids.ts. Each declared unique symbol is its own type, so no
other type can accidentally carry the same label.
A useful counterexample: two IDs of the same kindWhere a brand can’t help
moveStudent(user, from, to) takes two CourseIds. Swap from and to and everything still type-checks, because they are the
same kind.
Named fields, movePath({ user, from, to }), put the roles at the
call site where a reviewer can see them. You could brand SourceCourseId and TargetCourseId too, but a value that’s a source in one call is a target in the
next. Names fit roles; types fit kinds.
08 / The parts to watch
A brand keeps kinds apart. It doesn’t know where the text came from.
These are the places the protection ends.
The field mapping is still yours
fromFields(courseText, userText) compiles, as step 5 showed. Both fields are digits,
so the parser can’t tell which one it was handed. Review and test the one line where each field
meets its parser.
as and helpers relabel without checking
'73' as UserId compiles, and so did the helpers in section 04. Keep the assertion
inside the parser, where it follows a check.
TypeScript brands vanish at run time
An ID is a plain string when the code runs and a plain string in JSON. Anything arriving
from outside, a response, a URL, local storage, has to be parsed again before it’s a UserId.
Go’s defined types are distinct, not opaque
UserID(course), Enroll("bad", ""), and a zero UserID all compile; the Go tests run each one. If callers must go through a parser,
use a struct with an unexported field and decide what its zero value means.
Same kind, different roles
A sender and a recipient are both UserId. The brand protects kinds; named
parameters or fields protect roles.
Every boundary has to parse
Branded IDs mean one more line wherever text enters: the route, the response, the form. If an ID only ever travels from a response straight into a URL, that line may buy nothing.
09 / Make the call
What would you have to change tomorrow?
Give both designs a plausible change and follow the work it creates.
| The change | Plain strings | Distinct types |
|---|---|---|
| A call site swaps two IDs | Compiles, and the request goes to the wrong record. | Doesn’t compile. |
| enroll’s parameters are reordered | Every caller silently sends them swapped. | Every typed caller stops compiling until it’s fixed. |
| An ID arrives from JSON or a route | Use it directly. | Parse it first, one line at the boundary. |
| A function takes two IDs of the same kind | Same risk as ever. | Same risk. Use named fields. |
Reach for distinct types when values of different kinds share a representation and cross function calls. A user ID and a course ID, three helpers apart, is the moment.
Keep plain strings where a value is only displayed or passed straight through. A short function that reads both IDs from one row and uses them on the next line doesn’t need a type to keep them straight.
The question I’d leave beside the code is: should these two values be interchangeable?
10 / Take the idea with you
Explain the enrollment request without saying “branded type.”
“A user ID and a course ID are different types, so the compiler won’t let one stand in for the other, and the only way to get one is to parse it.” In a review, the words are nominal typing for what the brand adds, and primitive obsession for the bare strings it replaces.
Before moving on, jot down which swap compiled with aliases but not with brands, why fromFields(courseText, userText) still compiles, and one function in your own code
that takes two IDs as strings.
Connections to follow nextRelated lessons
- Parse, don’t validate is where the brand comes from: the parser returns the branded value.
- Value objects go further than a label: an amount in cents with its own arithmetic, a range with its own rules.
- Discriminated unions keep cases apart inside one value; brands keep separate values apart.
- Factory controls how a value is created, which is what makes an opaque type opaque.
- Making illegal states unrepresentable takes the same instinct from single values to whole data models.