TypeScript Opaque Types
An opaque type hides its representation behind a module boundary. The brand key is a unique symbol that the module keeps private, so code outside the module cannot build the type.
The email.ts file
The module keeps the symbol private and exports only the Email type with its maker and reader functions. The maker returns null for an address with no @ sign.
const emailBrand: unique symbol = Symbol('Email');
export type Email = string & { readonly [emailBrand]: 'Email' };
export function makeEmail(raw: string): Email | null {
if (!raw.includes('@')) {
return null;
}
return raw as Email;
}
export function domainOf(email: Email): string {
return email.slice(email.indexOf('@') + 1);
}A consumer uses only the exported functions
The consumer imports the maker and the reader from the module. It never sees the symbol, yet the checker tracks the Email type through the calls.
import { makeEmail, domainOf } from './email';
const email = makeEmail('ada@example.com');
if (email !== null) {
console.log(domainOf(email));
}
console.log(makeEmail('not-an-email'));Forging the type from outside is refused
A plain string assignment tries to skip the maker function entirely. The string lacks the private brand member, and the checker refuses it.
import type { Email } from './email';
const forged: Email = 'ada@example.com';
console.log(forged);Example
import { makeEmail, domainOf } from './email';
const addresses = ['ada@example.com', 'support@shop.dev', 'broken'];
for (const raw of addresses) {
const email = makeEmail(raw);
if (email === null) {
console.log(raw + ' -> rejected');
} else {
console.log(raw + ' -> ' + domainOf(email));
}
}Key points
- The module declares a unique symbol and uses it as the brand key.
- The symbol is not exported, so outside code cannot name the brand member.
- Maker functions inside the module validate raw values and return the opaque type.
- Reader functions inside the module are the way outside code works with the value.
In this example, every raw string passes through makeEmail, the one maker the module exports. Valid addresses come back as Email values that domainOf accepts, and the invalid string comes back as null.
Note
The privacy of the symbol is the whole mechanism, so the module exports the type and its functions, never the symbol itself.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท TypeScript Playground
Exercise
Why can code outside the email module not construct an Email value directly?