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.

For when and why to reach for brands (the conceptual guide and modeling trade-offs), see Branded types in the code-style guide. This page is the exhaustive Brand API reference.

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.

Everything Brand exports. The two spellings for a branded value are equivalent — pick whichever reads better:

import { Brand } from "effect"
// Intersection spelling (used above)
type A = number & Brand.Brand<"Score">
// Alias spelling — Branded<A, Key> is exactly A & Brand<Key>
type B = Brand.Branded<number, "Score">

The phantom tag interface. It contributes no runtime fields (the brand key lives in a unique symbol property), and it can carry multiple keys at once so a single value may hold several brands.

import { Brand } from "effect"
// One key
type UserId = number & Brand.Brand<"UserId">
// Several keys on one value (e.g. produced by Brand.all below)
type PositiveInt = number & Brand.Brand<"Positive"> & Brand.Brand<"Int">
const x = 1 as PositiveInt
const useId = (n: UserId) => n
// useId(x) // compile error: PositiveInt is not a UserId

A type alias for A & Brand<Key>, slightly more concise when you have a single brand key.

import { Brand } from "effect"
type Slug = Brand.Branded<string, "Slug"> // ≡ string & Brand.Brand<"Slug">
const Slug = Brand.nominal<Slug>()
const s = Slug("hello-world") // => "hello-world" (typed as Slug)

Builds a Constructor that applies no runtime validation and returns its input unchanged — purely for distinguishing values of the same base type.

import { Brand } from "effect"
type OrderId = Brand.Branded<string, "OrderId">
const OrderId = Brand.nominal<OrderId>()
console.log(OrderId("ord_42")) // => "ord_42"
console.log(OrderId.option("ord_42")) // => Some("ord_42") (always Some)
console.log(OrderId.is("anything")) // => true (always true)

Builds a Constructor from a single validation predicate. The filter returns true for a valid value, or a string message describing why it is invalid.

import { Brand } from "effect"
type NonEmpty = Brand.Branded<string, "NonEmpty">
const NonEmpty = Brand.make<NonEmpty>((s) =>
s.length > 0 || "Expected a non-empty string"
)
console.log(NonEmpty("hi")) // => "hi"
console.log(NonEmpty.option("")) // => None
// NonEmpty("") // throws BrandError("Expected a non-empty string")

Builds a Constructor from one or more Schema checks. Checks accumulate: every check must pass, and the failure message comes from the schema issue.

import { Brand, Schema } from "effect"
type PositiveInt = Brand.Branded<number, "PositiveInt">
const PositiveInt = Brand.check<PositiveInt>(
Schema.isInt(),
Schema.isGreaterThan(0)
)
console.log(PositiveInt(5)) // => 5
console.log(PositiveInt.option(2.5)) // => None (fails isInt)
console.log(PositiveInt.option(-1)) // => None (fails isGreaterThan(0))
console.log(PositiveInt.is(5)) // => true

Combines multiple constructors that share the same base type into one constructor; the combined checks must all pass. Use Brand.Brand.FromConstructor to name the resulting branded type.

import { Brand, Schema } from "effect"
type Int = Brand.Branded<number, "Int">
const Int = Brand.check<Int>(Schema.isInt())
type Positive = Brand.Branded<number, "Positive">
const Positive = Brand.check<Positive>(Schema.isGreaterThan(0))
// Carries both brands: number & Brand<"Int"> & Brand<"Positive">
const PositiveInt = Brand.all(Int, Positive)
type PositiveInt = Brand.Brand.FromConstructor<typeof PositiveInt>
console.log(PositiveInt(5)) // => 5
console.log(PositiveInt.option(2.5)) // => None (not an Int)
console.log(PositiveInt.option(-3)) // => None (not Positive)

If none of the combined constructors carry runtime checks, all behaves like a nominal constructor.

The callable interface shared by every brand constructor. Calling it directly validates and throws a BrandError; the methods provide non-throwing forms.

import { Brand } from "effect"
type Int = Brand.Branded<number, "Int">
const Int = Brand.make<Int>((n) =>
Number.isInteger(n) || `Expected ${n} to be an integer`
)
  • Call form — Int(x): returns B, throws BrandError on invalid input.

    console.log(Int(42)) // => 42
    // Int(1.5) // throws BrandError("Expected 1.5 to be an integer")
  • .option(x): returns Option<B>Some if valid, None otherwise.

    console.log(Int.option(42)) // => Some(42)
    console.log(Int.option(1.5)) // => None
  • .result(x): returns Result<B, BrandError>Success if valid, Failure carrying the BrandError otherwise.

    console.log(Int.result(42)) // => Success(42)
    console.log(Int.result(1.5)) // => Failure(BrandError("Expected 1.5 to be an integer"))
  • .is(x): a type guard returning boolean; narrows the value to the branded type when true.

    const n: number = 42
    if (Int.is(n)) {
    // here `n` is narrowed to Int
    }
    console.log(Int.is(42)) // => true
    console.log(Int.is(1.5)) // => false

The error produced when construction fails. It is not a JavaScript Error subclass — it is an error-like model carrying _tag: "BrandError", name, the underlying schema issue, and a message getter (the rendered issue). Calling a constructor throws this value, and .result exposes it as the failure.

import { Brand, Result } from "effect"
type Int = Brand.Branded<number, "Int">
const Int = Brand.make<Int>((n) =>
Number.isInteger(n) || `Expected ${n} to be an integer`
)
const r = Int.result(1.5)
if (Result.isFailure(r)) {
const err = r.failure
console.log(err._tag) // => "BrandError"
console.log(err.message) // => "Expected 1.5 to be an integer"
console.log(String(err)) // => "BrandError(Expected 1.5 to be an integer)"
}

Brand.Brand namespace (type-level helpers)

Section titled “Brand.Brand namespace (type-level helpers)”

The Brand.Brand namespace holds type-level utilities for working with brands and constructors. The two you reach for most:

Brand.Brand.FromConstructor<C> — extracts the branded type produced by a constructor C. Handy after Brand.all, or to avoid repeating a brand type.

import { Brand, Schema } from "effect"
const PositiveInt = Brand.check(Schema.isInt(), Schema.isGreaterThan(0))
type PositiveInt = Brand.Brand.FromConstructor<typeof PositiveInt>
// => number & Brand.Brand<...>

Brand.Brand.EnsureCommonBase<Brands> — the constraint Brand.all uses to verify every constructor shares one base type. If they don’t, it surfaces the type error "ERROR: All brands should have the same base type".

import { Brand, Schema } from "effect"
const Int = Brand.check(Schema.isInt()) // base: number
const Lower = Brand.check(Schema.isLowercased()) // base: string
// Brand.all(Int, Lower) // type error: bases (number vs string) differ

The remaining helpers are advanced type-level utilities you rarely write by hand:

  • Brand.Brand.Unbranded<B> — strips the brand to recover the underlying base type (Brand.Brand.Unbranded<UserId> is number). This is the type a constructor accepts as input.
  • Brand.Brand.Keys<B> — the union of brand key strings on B.
  • Brand.Brand.Brands<B> — the intersection of Brand<K> markers carried by B.
  • Branded types (code-style guide) — when and why to use brands.
  • Newtype — wrapping a value in a distinct type when a phantom tag is not enough.
  • SchemaSchema.brand / Schema.fromBrand to apply brands during decoding.
  • Result and Option — the return types of .result and .option.