← Concepts & practices
Concept Data modeling and type design

Branded / opaque types

Keep the meaning with the value.

You already pass IDs around as strings: getMember(orgId, userId), /courses/:courseId/students/:userId. Every one of them is a string, so nothing stops two of them trading places. Let’s give a user ID and a course ID types the compiler can tell apart, and find exactly where that protection ends.

TypeScriptGo One enrollment request, two implementations.

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
ids.ts
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.

TypeScriptReading
ids.ts
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 };
}
GoAlongside
ids.go
// 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.

Branded / opaque types

One swap, three outcomes.

FIELDSuserText “42”courseText “73”

Aliases for string
enrollStrings(courseText, userText)

User field “42”, course field “73”. With aliases, enrollStrings(courseText, userText) compiles and sends user=73 | course=42.

01/ 05
Call enrollStrings with swapped aliases

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
parseUserId is the only ordinary way to get a UserId. A search for as UserId finds 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.

The row has userId 42 and courseId 73. What does the handler send?

enroll(asUserId(dataset.courseId), asCourseId(dataset.userId)), where asUserId = (text: string) => text as UserId.

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.

Boundaries

Give IDs their kind

Route params, API responses, database rows.

Services and components

Take the kinds they need

enroll, removeStudent, the roster view.

The wire

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.

roster.ts
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.

ReactAlready in your code
StudentRow.tsx
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.

Values that share a representation but not a meaning
Where you’ve seen itWhat looks alikeWhat a mix-up costs
An API clientgetMember(orgId, userId)Another organization’s member, or a confusing 404.
Moneyamounts in cents and in dollarsA charge a hundred times too large.
Durationsmilliseconds and secondsA 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.

Read about branded types ↗

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.

Look at time.Duration ↗

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.

Read about unique symbols ↗
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.

How a change affects plain strings and distinct ID types
The changePlain stringsDistinct types
A call site swaps two IDsCompiles, and the request goes to the wrong record.Doesn’t compile.
enroll’s parameters are reorderedEvery caller silently sends them swapped.Every typed caller stops compiling until it’s fixed.
An ID arrives from JSON or a routeUse it directly.Parse it first, one line at the boundary.
A function takes two IDs of the same kindSame 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

Take the IDs into your editor. Add a TeacherId, give enroll an approver, and see which call sites the compiler asks you to look at.

Back to Concepts & practices →