Skip to content

Branded Types

TypeScript is structurally typed: a UserId that is “just a number” is interchangeable with any other number, so nothing stops you from passing an OrderId — or a raw 42 — where a UserId is expected. Branded types add a phantom tag to a value so the compiler treats it as a distinct, nominal type while it remains an ordinary number (or string) at runtime. The Brand module gives you the tag and a constructor that produces branded values.

import { Brand } from "effect"
// A branded number type: still a number at runtime, distinct at compile time
type UserId = number & Brand.Brand<"UserId">
// `nominal` builds a constructor that applies no runtime validation
const UserId = Brand.nominal<UserId>()
const getUser = (id: UserId) => `user ${id}`
getUser(UserId(1)) // ok
// getUser(1) // compile error: number is not a UserId

Brand.Brand<"UserId"> is the phantom tag, and Brand.nominal<UserId>() returns a constructor that simply re-labels a plain number as a UserId. The mislabeled call getUser(1) no longer type-checks, so the two “kinds of number” can never be confused.

Use Brand.nominal when you only want to distinguish values, with no runtime check. The constructor is the identity at runtime:

import { Brand } from "effect"
type Email = string & Brand.Brand<"Email">
type Username = string & Brand.Brand<"Username">
const Email = Brand.nominal<Email>()
const Username = Brand.nominal<Username>()
const email = Email("john@example.com")
const name = Username("john")
// Email and Username are now incompatible even though both are strings

Often a brand carries an invariant: a UserId must be a positive integer, an Email must contain an @. Brand.make builds a constructor that validates its input. The filter returns true when the value is valid, or an error message string when it is not.

import { Brand } from "effect"
type Int = number & Brand.Brand<"Int">
// The filter returns true (valid) or an error message (invalid)
const Int = Brand.make<Int>((n) =>
Number.isInteger(n) || `Expected ${n} to be an integer`
)
console.log(Int(42)) // 42
// Int(1.5) // throws BrandError: "Expected 1.5 to be an integer"

By default, calling the constructor throws a BrandError on invalid input. To handle failure as a value instead, the constructor also exposes total variants:

import { Brand } from "effect"
type Int = number & Brand.Brand<"Int">
const Int = Brand.make<Int>((n) =>
Number.isInteger(n) || `Expected ${n} to be an integer`
)
// `.option` returns Option<Int>
console.log(Int.option(42)) // { _id: 'Option', _tag: 'Some', value: 42 }
console.log(Int.option(1.5)) // { _id: 'Option', _tag: 'None' }
// `.result` returns Result<Int, BrandError>
console.log(Int.result(1.5))
// { _id: 'Result', _tag: 'Failure', failure: BrandError(...) }
// `.is` is a type guard
console.log(Int.is(42)) // true
console.log(Int.is(1.5)) // false

So every brand constructor gives you four ways to construct, depending on how you want to treat invalid input: Int(x) (throws), Int.option(x), Int.result(x), and Int.is(x).

When your validation logic already exists as Schema checks, use Brand.check to reuse them. Multiple checks accumulate, and the failure message comes from the schema:

import { Brand, Schema } from "effect"
type PositiveInt = number & Brand.Brand<"PositiveInt">
// Combine schema checks into a branded constructor
const PositiveInt = Brand.check<PositiveInt>(
Schema.isInt(),
Schema.isGreaterThan(0)
)
console.log(PositiveInt(5)) // 5
console.log(PositiveInt.option(-1)) // None — fails the "> 0" check

If a value enters your system through a Schema (decoding JSON from an HTTP request, say), you usually want the brand applied as part of decoding rather than as a separate step. The Schema module’s .pipe(Schema.brand("...")) attaches a brand to a decoded value, so the parsed result is already a branded type with its invariants checked. Reach for the standalone Brand module when you are branding values that do not flow through a schema.