Skip to content

Branded Types

TypeScript is structurally typed: two types with the same shape are interchangeable. A UserId that is just number and an OrderId that is also number can be passed to each other’s functions with no complaint from the compiler — exactly the kind of mix-up that causes real bugs. Branded types attach a compile-time tag to a value so the type system keeps these apart, while leaving the runtime representation untouched.

import { Brand } from "effect"
// `Branded<base, key>` = the base type plus a phantom brand. At runtime a
// UserId is still a number; the brand only exists in the type system.
type UserId = Brand.Branded<number, "UserId">
type OrderId = Brand.Branded<number, "OrderId">
// `nominal` makes a constructor with no runtime check — it only applies the tag.
const UserId = Brand.nominal<UserId>()
const OrderId = Brand.nominal<OrderId>()
const getUser = (id: UserId) => `loading user ${id}`
getUser(UserId(1)) // ✅ ok
// @ts-expect-error — an OrderId is not a UserId, even though both are numbers
getUser(OrderId(1))
// @ts-expect-error — a raw number is missing the brand
getUser(1)

UserId(1) returns the very same 1 at runtime; the difference lives entirely in the types, so branding costs nothing at execution time.

Reach for a branded type when a primitive carries meaning that the compiler should enforce:

  • Distinct identifiers that share a representation — UserId vs OrderId, both string or both number.
  • Validated primitives — a number that has been checked to be a positive integer, a string that is a non-empty email, a token that has been normalized. The brand records that the validation happened, so downstream code can trust it without re-checking.

If a value never gets confused with another and needs no validation guarantee, plain types are fine — don’t brand reflexively.

The Brand module gives you two kinds of constructor.

Brand.nominal only applies the tag. Use it for identifiers where any value of the base type is already valid and you just want them kept distinct.

import { Brand } from "effect"
type Sku = Brand.Branded<string, "Sku">
const Sku = Brand.nominal<Sku>()
const sku = Sku("ABC-123") // always succeeds

A validated constructor throws a BrandError when called with an invalid value. That is convenient at trust boundaries, but you usually want a non-throwing form.

Every constructor — nominal or validated — exposes methods that validate without throwing, so you can integrate branding into Effect flows cleanly.

import { Brand, Effect } from "effect"
type PositiveInt = Brand.Branded<number, "PositiveInt">
const PositiveInt = Brand.make<PositiveInt>(
(n) => (Number.isInteger(n) && n > 0) || "Expected a positive integer"
)
// `.option` → Option<PositiveInt>
const maybe = PositiveInt.option(5)
// `.result` → Result<PositiveInt, BrandError>, ideal inside Effect.gen
const parseQuantity = Effect.fn("parseQuantity")(function*(raw: number) {
const result = PositiveInt.result(raw)
if (result._tag === "Failure") {
return yield* Effect.fail(`invalid quantity: ${result.failure.message}`)
}
return result.success
})
// `.is` → a type guard you can use to narrow
declare const value: number
if (PositiveInt.is(value)) {
// value is now PositiveInt here
}

When a value must satisfy several independent constraints, define each brand separately and merge their constructors with Brand.all. The combined constructor enforces every check, and the resulting type carries every tag.

import { Brand } from "effect"
type Int = Brand.Branded<number, "Int">
const Int = Brand.make<Int>(
(n) => Number.isInteger(n) || "Expected an integer"
)
type Positive = Brand.Branded<number, "Positive">
const Positive = Brand.make<Positive>(
(n) => n > 0 || "Expected a positive number"
)
// PositiveInt requires BOTH checks to pass.
const PositiveInt = Brand.all(Int, Positive)
type PositiveInt = Brand.Brand.FromConstructor<typeof PositiveInt>
PositiveInt(10) // ✅
// PositiveInt(-5) → BrandError("Expected a positive number")
// PositiveInt(3.14) → BrandError("Expected an integer")

All brands passed to Brand.all must share the same base type (here, number).

When the value flows through a Schema — parsing config, decoding a request body, validating a database row — brand it at the schema level so decoded values come out already tagged. Schema.brand adds the nominal tag, while Schema.fromBrand also applies a validating constructor’s checks during decoding.

import { Brand, Schema } from "effect"
// Tag a schema's output. `Schema.Int` already validates integer-ness;
// `.pipe(Schema.brand(...))` makes the result a distinct branded type.
const UserId = Schema.Int.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type // number & Brand<"UserId">
// Or reuse an existing Brand constructor so its checks run during decoding.
type Slug = Brand.Branded<string, "Slug">
const Slug = Brand.make<Slug>(
(s) => /^[a-z0-9-]+$/.test(s) || "Expected a url-safe slug"
)
const SlugSchema = Schema.String.pipe(Schema.fromBrand("Slug", Slug))

This keeps validation and branding in one place: every value that exits the schema is guaranteed to satisfy the brand’s invariants, and the type system propagates that guarantee through the rest of your program.