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 numbersgetUser(OrderId(1))// @ts-expect-error — a raw number is missing the brandgetUser(1)UserId(1) returns the very same 1 at runtime; the difference lives entirely
in the types, so branding costs nothing at execution time.
When to use branding
Section titled “When to use branding”Reach for a branded type when a primitive carries meaning that the compiler should enforce:
- Distinct identifiers that share a representation —
UserIdvsOrderId, bothstringor bothnumber. - Validated primitives — a
numberthat has been checked to be a positive integer, astringthat 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.
Nominal vs validated constructors
Section titled “Nominal vs validated constructors”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 succeedsBrand.make builds a constructor from a validation predicate. The predicate
returns true (or undefined) when the value is valid, or a message string
describing why it failed.
import { Brand } from "effect"
type PositiveInt = Brand.Branded<number, "PositiveInt">
const PositiveInt = Brand.make<PositiveInt>( (n) => (Number.isInteger(n) && n > 0) || "Expected a positive integer")
const good = PositiveInt(3) // ✅ branded value// PositiveInt(-1) would throw a BrandError at runtimeA 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.
Safe construction: option, result, is
Section titled “Safe construction: option, result, is”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.genconst 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 narrowdeclare const value: numberif (PositiveInt.is(value)) { // value is now PositiveInt here}Combining brands with Brand.all
Section titled “Combining brands with Brand.all”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).
Branding through Schema
Section titled “Branding through Schema”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.