Phase 7 · Advanced and tooling · Lesson 7.2
AdvancedBranded types and declaration merging
Simulate nominal types with brands and smart constructors, then learn how TypeScript merges declarations: interfaces, namespaces, module augmentation and declare global.
20 min
chargeUser(orderId, userId) compiles. Both arguments are string, so TypeScript is perfectly happy you swapped them, and you find out when the wrong customer is charged. Structural typing only compares shapes, and two strings have the same shape. This lesson shows how to get nominal ("by name") safety when you need it, and then covers the other side of TypeScript's open type system: declaration merging and augmentation.
When structural typing hurts
Type aliases are just names for an existing type. They add no safety:
type UserId = string;
type OrderId = string;
function refund(order: OrderId, user: UserId) {}
const userId: UserId = "u_42";
const orderId: OrderId = "o_7";
refund(userId, orderId); // swapped, and no error: both are just stringThe same happens with objects: { lat: number; lng: number } and { lat: number; lng: number } from two different libraries are interchangeable. Usually that's a feature. For IDs, currencies, units and validated strings, it's a bug magnet.
Branded types
A brand is a fake property that exists only in the type system. Intersecting string with it makes a type that plain strings are not assignable to:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function refund(order: OrderId, user: UserId) {}
const userId = "u_42" as UserId;
const orderId = "o_7" as OrderId;
refund(orderId, userId); // ok
// @ts-expect-error -- Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
refund(userId, orderId);
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'OrderId'.
refund("o_7", userId);▶ Try it in the TypeScript Playground
Key facts:
- Zero runtime cost. At runtime a
UserIdis a plain string.__brandnever exists; you must never read it. - Still usable as the base type. A
UserIdis a subtype ofstring, souserId.toUpperCase()and passing it where astringis expected both work. - The two brands conflict:
"UserId"and"OrderId"are different literal types for the same property, so the types are not assignable to each other.
Quick check
Which line errors?
type UserId = string & { readonly __brand: "UserId" };
declare const id: UserId;
declare const raw: string;
const s: string = id; // Line A
const u: UserId = raw; // Line Bunique symbol brands
A string-keyed __brand shows up in autocomplete and could collide with a real property. A unique symbol key is invisible to other modules and cannot collide:
declare const brand: unique symbol;
type Brand<T, Name extends string> = T & { readonly [brand]: Name };
type Email = Brand<string, "Email">;
type Cents = Brand<number, "Cents">;
declare const price: Cents;
const doubled = price * 2;
// ^? const doubled: number (arithmetic drops the brand)declare const brand: unique symbol creates a symbol type that exists only at compile time. Nothing is emitted. The generic Brand helper keeps every brand consistent across a codebase.
Smart constructors: the only door in
A brand is only as trustworthy as the code that creates it. If as UserId is sprinkled everywhere, the brand proves nothing. The pattern is a smart constructor: one function that validates, and is the only place that casts.
declare const brand: unique symbol;
type Brand<T, Name extends string> = T & { readonly [brand]: Name };
type Email = Brand<string, "Email">;
function parseEmail(input: string): Email {
if (!/^[^@\s]+@[^@\s]+$/.test(input)) {
throw new Error(`Invalid email: ${input}`);
}
return input as Email; // the one sanctioned cast
}
function isEmail(input: string): input is Email {
return /^[^@\s]+@[^@\s]+$/.test(input);
}
function sendWelcome(to: Email) {}
const raw: string = "ada@example.com";
sendWelcome(parseEmail(raw));
if (isEmail(raw)) {
sendWelcome(raw); // narrowed to Email by the type guard
}Now Email means "this string passed validation". Every function that takes an Email can skip re-checking. This is often called parse, don't validate: turn unchecked input into a type that proves the check happened.
Spot the error
This compiles, but the brand is not doing its job. What's wrong?
type Meters = number & { readonly __brand: "Meters" };
type Feet = number & { readonly __brand: "Feet" };
const toMeters = (feet: number) => (feet * 0.3048) as Meters;
function climb(height: Meters) {}
const everest = 29032 as Feet;
const k2 = 8611 as Meters;
climb(toMeters(k2)); // converts meters as if they were feetShow the answer
toMeters accepts a plain number, and every branded number is a number, so a Meters value slips in where feet were intended. The brand only protects parameters that ask for it. Type the input as Feet:
type Meters = number & { readonly __brand: "Meters" };
type Feet = number & { readonly __brand: "Feet" };
const toMeters = (feet: Feet) => (feet * 0.3048) as Meters;
function climb(height: Meters) {}
const everest = 29032 as Feet;
const k2 = 8611 as Meters;
climb(toMeters(everest)); // ok
// @ts-expect-error -- Argument of type 'Meters' is not assignable to parameter of type 'Feet'.
climb(toMeters(k2));Declaration merging
Now the opposite idea. TypeScript lets several declarations with the same name combine into one. This is how libraries are extended and how the DOM and ES lib files are built.
Interface + interface
Two interfaces with the same name in the same scope merge their members:
interface Settings {
theme: "light" | "dark";
}
interface Settings {
fontSize: number;
}
const s: Settings = { theme: "dark", fontSize: 14 }; // needs bothRules worth knowing:
- Non-function members with the same name must have the identical type, otherwise it's an error ("Subsequent property declarations must have the same type").
- Methods with the same name become overloads. Overloads from the later interface come first, except that signatures with a single string-literal parameter type are hoisted to the top.
typealiases never merge. Declaring the same alias twice is a "Duplicate identifier" error. This is the practical reason to useinterfacefor types you want others to extend.
// TypeScript reports the duplicate on both declarations:
// @ts-expect-error -- Duplicate identifier 'Point'.
type Point = { x: number };
// @ts-expect-error -- Duplicate identifier 'Point'.
type Point = { y: number };Namespace + function, class or enum
A namespace can merge with a function, class or enum of the same name to attach extra members. This models JavaScript patterns like a function with static properties.
function format(value: number): string {
return value.toFixed(format.precision);
}
namespace format {
export const precision = 2;
}
class Temperature {
constructor(public celsius: number) {}
}
namespace Temperature {
export function fromFahrenheit(f: number) {
return new Temperature(((f - 32) * 5) / 9);
}
}
enum Level { Low, High }
namespace Level {
export function parse(s: string): Level {
return s === "high" ? Level.High : Level.Low;
}
}
format(3.14159);
Temperature.fromFahrenheit(212);
Level.parse("high");The function or class must come before the namespace in the file, because the namespace emits code that assigns onto it. A class can also merge with an interface of the same name, which adds instance members to the type (without implementing them, so use this with care).
Quick check
Which declaration pair produces an error?
Module augmentation
Module augmentation adds members to a type that lives in another module, such as a library's config interface. You write declare module "name" with the module's specifier, inside a file that is itself a module:
It needs two files: declare module "config-lib" inside a module file is an augmentation, and augmentation only works on a module the compiler can resolve. First, the library's types:
// config-lib.d.ts (an ambient module declaration, script file, no imports)
declare module "config-lib" {
export interface Config {
url: string;
}
export function load(): Config;
}Then your code augments it:
// app.ts (a module: it has an import)
import { load } from "config-lib";
declare module "config-lib" {
interface Config {
retries: number; // merged into the library's Config
}
}
const config = load();
config.retries; // number
config.url; // stringRules and gotchas:
- The augmenting file must be a module (have an
importorexport). In a script file,declare module "x"is instead an ambient module declaration that defines the module from scratch. - You can only add to existing declarations (merge interfaces, add namespaces). You cannot add new top-level exports that don't exist in the original, and you cannot change existing member types.
- The module name must resolve, otherwise: "Invalid module name in augmentation, module 'x' cannot be found."
- Libraries often expose an empty interface precisely so you can augment it (a "registry" interface, such as a theme or a map of event names).
Global augmentation with declare global
From inside a module, declare global { ... } reaches the global scope. Use it to add properties to built-in types like Window or Array:
declare global {
interface Window {
appVersion: string;
}
interface Array<T> {
last(): T | undefined;
}
}
Array.prototype.last = function () {
return this[this.length - 1];
};
window.appVersion = "1.4.0";
const final = [1, 2, 3].last();
// ^? const final: number | undefinedThe type declaration and the runtime implementation are separate: declare global only tells the compiler the member exists. If you forget the Array.prototype.last = ... line, everything type-checks and crashes at runtime.
Recap
- Structural typing makes same-shaped types interchangeable; aliases like
type UserId = stringadd no safety. - Brands (
T & { readonly __brand: "X" }or aunique symbolkey) simulate nominal types at zero runtime cost. - Create branded values in one smart constructor or type guard that validates; avoid scattered
ascasts. - Interfaces merge; same-named methods become overloads; type aliases never merge.
- Namespaces merge with functions, classes and enums to add static members; the value must be declared first.
- Module augmentation (
declare module "x"inside a module) adds to another module's types;declare globaladds to global types. Both are type-only: provide the runtime code yourself.
Interview cards
1 / 7