Branded types in TypeScript are often considered an advanced typing technique — sometimes misunderstood, sometimes overlooked. But when combined with structural patterns like the builder pattern, brands offer a powerful way to ensure correctness at compile time, not just runtime. This post walks through:
- What branded types are, and why you'd use them
- A real-world pattern combining brands with builders
- The TypeScript trick that enforces "built" objects are actually… built
What Is a Branded Type?
A brand in TypeScript is a technique to distinguish between two types that are structurally identical — by attaching an extra phantom property only understood by the type checker.
You can think of it as a compile-time tag or assertion — something that can only be set intentionally, in code you control. For example:
type UserId = string & { __brand: "UserId" };
function makeUserId(id: string): UserId {
return id as UserId;
}
Even though UserId is just a string at runtime, TypeScript now treats it as a separate, branded type. This prevents you from accidentally passing in a plain string where a UserId is expected.
// Define your branded type and constructor
type UserId = string & { __brand: "UserId" };
function makeUserId(id: string): UserId {
return id as UserId;
}
// A function that only accepts a UserId
const logUser = (id: UserId) => {
console.log({ id }, "User");
};
// Correct usage:
const userJohn = makeUserId("john|123");
logUser(userJohn); // ✅ No TS error
// Incorrect usage:
const fakeUser = "john|123";
logUser(fakeUser);
// ❌ TS Error: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Why is this useful? Because sometimes a value can be validly represented as multiple shapes or phases in your code, but you want to restrict when and how they’re used.
The Builder Pattern, Enforced With Brands
In a recent team session, we explored how to use this concept to make our builder pattern safer and more predictable.
The Problem
Imagine you're creating a property validator class PropertyValidator. It needs to define the requirements, restraints, and conversions for the given property. Things like, is the property required, is it expected to be an integer, or string, or valid date, etc.
The catch: The validator isn't ready to use until you call .build() - which finalizes its configuration by setting the appropriate validation method .validate(value), depending on the type of property.
build(this): PropertyValidator {
switch (this.type) {
case 'string':
this.validate = this.validateString.bind(this)
case 'int':
this.validate = this.validateInt.bind(this)
case 'date':
this.validate = this.validateDate.bind(this)
default:
this.throwUnhandledPropertyTypeError()
}
return this;
}
We ran into a bug in testing because we forgot to call .build() for a property so validate() was never initialized. Typescript didn't complain - and it crashed at runtime.
So we fixed it with a brand.
The Pattern
Here's a simplified version of how it works.
declare const __isBuilt: unique symbol;
type BuiltPropertyValidator<R, T> = PropertyValidator<R, T> & { readonly [__isBuilt]: true };
export class PropertyValidator<R, T> {
property: keyof R;
type: string;
validate: (value: unknown) => Promise<T | null>;
constructor(property: keyof R) {
this.property = property;
}
isString() {
this.type = 'string';
return this as PropertyValidator<R, string>;
}
isInt() {
this.type = 'int';
return this as PropertyValidator<R, number>;
}
build(this): BuiltPropertyValidator {
if (!this.type) {
throw new NoTypeSpecifiedError(this.property);
}
switch (this.type) {
case 'string':
this.validate = this.validateString.bind(this);
break;
case 'int':
this.validate = this.validateInt.bind(this);
break;
default:
throw new UnsupportedTypeError(this.type, this.property);
}
}
async validateString(v: unknown): Promise<string | null> {
// string validation logic here.
}
async validateInt(v: unknown): Promise<number | null> {
// integer validation logic here.
}
}
We now have a tool to construct a validator for a given record type.
import { PropertyValidator, BuiltPropertyValidator } from './propertyValidator';
// Type maps keys of the given type to a BuiltPropertyValidator type.
// Ensures, `.build()` is called for each property.
export type RecordValidator<R> = Record<keyof R, BuiltPropertyValidator<R, R[keyof R]>>;
type User = {
name: string;
age: number;
};
declare const __isValidated: unique symbol;
type ValidatedUser = User & { readonly [__isValidated]: true };
const UserValidator: RecordValidator<User> = {
name: new PropertyValidator<User, string>('name').isString().build(), // no typescript error
age: new PropertyValidator<User, number>('age').isInt(), // typescript error
};
const validateUser = (user: User): ValidatedUser => {
const validatedUser = {} as User;
for (const property of user) {
const validator = UserValidator[property];
// Here is where the code would crash at runtime whenever the validator defined for the property forgot to call `build()`
const validatedValue = await validator.validate(property);
validatedUser[property] = validatedValue;
}
return validatedUser as ValidatedUser;
};
With the help of Typescript and the concept of Brands, now the code no longer fails at runtime, and the source of the error is now caught by typescript before runtime.
A developer defining a PropertyValidator now will have an early warning if they forget to call build() for any of its implementations.
Why This Works So Well
1. Compile-Time Guarantees
By tagging built objects with a brand, we effectively say: “Only values that came from the .build() method are allowed here.” That’s a strong guarantee — stronger than most plain type assertions or object shapes can give you.
2. Preventing Class Confusion
Without brands, TypeScript sees any object that looks like a Validator as good enough. That’s structural typing at work — a powerful feature, but sometimes a dangerous one.
By branding the result of .build(), we force callers to be intentional about the transition between a builder and the usable object it produces.
3. Cleaner Developer Flow
With this setup, developers working with the validator system get clear signals. You can't use an object unless it's fully set up. TypeScript enforces that, not just our runtime tests.
Optional Twist: Removing Builder Methods from the Built Type
One thing that came up in our discussion: what if we want to make sure once an object is “built,” it can’t be built again? Or prevent further modification?
A more advanced pattern would return an entirely different type from build(), without any of the builder methods:
class PropertyValidatorBuilder<T> {
private validator: Partial<Validator> = {};
setType(type: "int" | "date") {
this.validator.type = type;
return this;
}
build(): BuiltValidator {
return {
...this.validator,
validate: () => { /* ... */ }
} as BuiltValidator;
}
}
And the BuiltValidator type would not expose setType() or build() — locking it down.
You can even define an interface just for the final shape:
interface BuiltValidator {
validate(input: unknown): boolean;
__brand: "Built";
}
Now there’s no way to accidentally reuse or mutate the builder after the fact.
Final Thoughts
This combination of branded types and the builder pattern might seem over-engineered for small cases, but in real-world systems — especially ones that involve validation, state transformation, or multi-step configuration — this technique provides:
- Type safety without runtime overhead
- Intentional state transitions
- Clearer APIs for consumers
In short: You get all the flexibility of TypeScript’s type system and some of the guardrails of more rigid languages.
If you’ve ever hit bugs from half-configured objects, forgotten final steps, or runtime assumptions that TypeScript didn’t catch — branded builders might be worth adding to your toolbox.
.png)
