Brands
Brands add "nominal typing" to TypeScript's structural type system. They help you distinguish between values that have the same underlying type but different semantic meanings.
The Problem Brands Solve
Consider this code:
// Both are strings, but they're not interchangeable!
type UserId = string
type OrderId = string
function getOrder(orderId: OrderId): Order { /* ... */ }
const userId: UserId = "user_123"
getOrder(userId) // ✅ TypeScript allows this - but it's wrong!TypeScript uses structural typing, so UserId and OrderId are indistinguishable. Brands fix this:
import { Schema, Brand } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type // string & Brand<"UserId">
const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
type OrderId = typeof OrderId.Type // string & Brand<"OrderId">
function getOrder(orderId: OrderId): Order { /* ... */ }
const userId = Schema.decodeSync(UserId)("user_123")
getOrder(userId) // ❌ TypeScript error! Type 'Brand<"UserId">' not assignableCreating Branded Types
Basic Brand
import { Schema } from "effect"
const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email")
)
type Email = typeof Email.Type
// string & Brand<"Email">
// Create branded values
const email = Schema.decodeSync(Email)("alice@example.com")
// email has type: string & Brand<"Email">
// Use with regular strings
const regular = "hello"
const branded: Email = regular // ❌ Type error!With Validation
Brands work with any schema, including those with filters:
import { Schema } from "effect"
const PositiveInt = Schema.Number.pipe(
Schema.int(),
Schema.positive(),
Schema.brand("PositiveInt")
)
type PositiveInt = typeof PositiveInt.Type
// number & Brand<"PositiveInt">
// Validation + branding
Schema.decodeSync(PositiveInt)(42) // ✅ 42 as PositiveInt
Schema.decodeSync(PositiveInt)(-5) // ❌ ParseError: not positive
Schema.decodeSync(PositiveInt)(3.14) // ❌ ParseError: not integerSymbol Brands
Use symbols for brands that shouldn't conflict:
import { Schema } from "effect"
const UserIdSymbol = Symbol.for("UserId")
const UserId = Schema.String.pipe(Schema.brand(UserIdSymbol))
type UserId = typeof UserId.Type
// string & Brand<typeof UserIdSymbol>Brand Schema Interface
Branded schemas provide a make function:
import { Schema } from "effect"
const UserId = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.brand("UserId")
)
// make() validates and brands
const id1 = UserId.make("user_123") // ✅ Validates
// Skip validation (use carefully!)
const id2 = UserId.make("", { disableValidation: true }) // No validationMultiple Brands
Stack multiple brands on the same value:
import { Schema } from "effect"
const PositiveInt = Schema.Number.pipe(
Schema.int(),
Schema.positive(),
Schema.brand("PositiveInt")
)
const UserId = PositiveInt.pipe(
Schema.brand("UserId")
)
type UserId = typeof UserId.Type
// number & Brand<"PositiveInt"> & Brand<"UserId">fromBrand with Effect's Brand
Use with Effect's Brand module for more control:
import { Schema, Brand } from "effect"
// Define brand constructor with Effect's Brand
type UserId = string & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
// Create schema from brand
const UserIdSchema = Schema.String.pipe(Schema.fromBrand(UserId))
// Now you can use both:
const id1 = UserId.make("user_123") // Using Brand directly
const id2 = Schema.decodeSync(UserIdSchema)("user_123") // Using SchemaPractical Examples
Domain Identifiers
import { Schema } from "effect"
// Database IDs
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
const ProductId = Schema.String.pipe(Schema.brand("ProductId"))
// Composite types use the brands
const Order = Schema.Struct({
id: OrderId,
userId: UserId,
products: Schema.Array(Schema.Struct({
productId: ProductId,
quantity: Schema.Number.pipe(Schema.int(), Schema.positive())
}))
})Validated Strings
import { Schema } from "effect"
const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email")
)
const PhoneNumber = Schema.String.pipe(
Schema.pattern(/^\+?[\d\s-]{10,}$/),
Schema.brand("PhoneNumber")
)
const Url = Schema.String.pipe(
Schema.pattern(/^https?:\/\/.+/),
Schema.brand("Url")
)
const User = Schema.Struct({
email: Email,
phone: Schema.optional(PhoneNumber),
website: Schema.optional(Url)
})Monetary Values
import { Schema } from "effect"
const USD = Schema.Number.pipe(
Schema.finite(),
Schema.brand("USD")
)
const EUR = Schema.Number.pipe(
Schema.finite(),
Schema.brand("EUR")
)
// Can't accidentally mix currencies!
function addUSD(a: typeof USD.Type, b: typeof USD.Type): typeof USD.Type {
return USD.make(a + b)
}
const dollars = Schema.decodeSync(USD)(100)
const euros = Schema.decodeSync(EUR)(50)
addUSD(dollars, dollars) // ✅
addUSD(dollars, euros) // ❌ Type error!Safe Numeric Types
import { Schema } from "effect"
const Percentage = Schema.Number.pipe(
Schema.between(0, 100),
Schema.brand("Percentage")
)
const Probability = Schema.Number.pipe(
Schema.between(0, 1),
Schema.brand("Probability")
)
const Age = Schema.Number.pipe(
Schema.int(),
Schema.between(0, 150),
Schema.brand("Age")
)
// Type-safe calculations
function adjustAge(age: typeof Age.Type, years: number): typeof Age.Type {
return Age.make(age + years) // Re-validates!
}Brands in Transformations
Brands are preserved through transformations:
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const User = Schema.Struct({
id: UserId,
createdAt: Schema.DateFromString
})
type User = typeof User.Type
// {
// readonly id: string & Brand<"UserId">
// readonly createdAt: Date
// }
// Decode preserves the brand
const user = Schema.decodeSync(User)({
id: "user_123",
createdAt: "2024-01-15"
})
// user.id has type: string & Brand<"UserId">Type Assertions
Sometimes you need to assert a value is branded without validation:
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
// ⚠️ Unsafe assertion (skips validation)
function unsafeUserId(s: string): UserId {
return s as UserId
}
// ✅ Safe creation (validates)
function safeUserId(s: string): UserId {
return Schema.decodeSync(UserId)(s)
}Type Assertions
Avoid type assertions (as UserId). They bypass validation and defeat the purpose of brands. Always use Schema.decodeSync or make() to create branded values.
Best Practices
1. Brand Domain Entities
// ✅ Brand IDs to prevent mixing
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
// ❌ Raw strings are error-prone
const userId: string = "user_123"
const orderId: string = "order_456"2. Combine with Validation
// ✅ Brand + validation = bulletproof
const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email")
)
// ⚠️ Brand alone doesn't validate format
const UnsafeEmail = Schema.String.pipe(Schema.brand("Email"))3. Use Descriptive Brand Names
// ✅ Clear semantic meaning
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const CentsAmount = Schema.Number.pipe(Schema.brand("CentsAmount"))
// ❌ Generic or unclear
const Id = Schema.String.pipe(Schema.brand("Id")) // Id of what?
const Num = Schema.Number.pipe(Schema.brand("Num")) // What kind?4. Create Brand Modules
// brands.ts
import { Schema } from "effect"
export const UserId = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.brand("UserId")
)
export type UserId = typeof UserId.Type
export const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email")
)
export type Email = typeof Email.Type
// Usage
import { UserId, Email } from "./brands"Summary
| Pattern | Description |
|---|---|
Schema.brand("Name") | Add nominal typing |
Schema.fromBrand(constructor) | Use Effect Brand constructor |
BrandedSchema.make(value) | Create branded value (validates) |
| Multiple brands | Stack brand() calls |
Next Steps
- Classes - Schema-backed TypeScript classes
- Unions - Union types and discriminated unions
- Best Practices - Schema design patterns