Skip to content

Newtype

A newtype is a purely compile-time wrapper around a carrier type (the underlying primitive or object). It prevents you from accidentally mixing structurally identical values — a UserId and an OrderId are both number at runtime, but the type system keeps them apart. Unlike a Brand, which is an intersection (number & Brand<"UserId">) and so still flows wherever a plain number is expected, a newtype is fully opaque: a Newtype<"UserId", number> cannot be used where a number is required until you explicitly unwrap it.

Three terms describe a newtype:

  • Carrier — the underlying type being wrapped (e.g. string, number).
  • Key — a unique string literal that distinguishes one newtype from another (e.g. "UserId", "OrderId").
  • Iso — a lossless two-way conversion between a newtype and its carrier, created with Newtype.makeIso.

There is zero runtime overhead: every operation in this module is an identity cast. The tag exists only in the type system.

Define a newtype by declaring an interface that extends Newtype.Newtype<Key, Carrier>. Then build an iso to wrap (set) and unwrap (get) values.

import { Newtype } from "effect"
// 1. Define a newtype: key "Label", carrier `string`
interface Label extends Newtype.Newtype<"Label", string> {}
// 2. Create an iso for wrapping / unwrapping
const labelIso = Newtype.makeIso<Label>()
// 3. Wrap a raw string into the newtype
const myLabel: Label = labelIso.set("hello")
// 4. Unwrap back to the carrier
const raw: string = labelIso.get(myLabel)
// => "hello"

The iso returned by makeIso is an Optic.Iso, so it composes with the rest of the Optic API (lenses, prisms) when a newtype sits inside a larger structure.

If you only need to read the inner value and never wrap new ones, use Newtype.value — it is the get direction of the iso on its own.

import { Newtype } from "effect"
interface Label extends Newtype.Newtype<"Label", string> {}
const iso = Newtype.makeIso<Label>()
const label = iso.set("hello")
const raw: string = Newtype.value(label)
// => "hello"

Two newtypes that share the same key string are assignable to each other, because the tag is keyed entirely on that literal. Always choose a distinct key per newtype.

import { Newtype } from "effect"
interface UserId extends Newtype.Newtype<"UserId", number> {}
interface OrderId extends Newtype.Newtype<"OrderId", number> {}
const userIso = Newtype.makeIso<UserId>()
const orderIso = Newtype.makeIso<OrderId>()
const user: UserId = userIso.set(1)
// const order: OrderId = user
// ^ Type error: 'UserId' is not assignable to 'OrderId'

Both Brand and Newtype give a primitive a distinct identity, but they make different tradeoffs:

BrandNewtype
Relationship to carrierIntersection: number & Brand<"Id"> — still usable as a numberOpaque: not assignable to the carrier without unwrapping
Runtime validationYes — Brand.make / Brand.check can validate and reject values (Brand.nominal skips it)No — purely structural, no validation
UnwrappingNot needed; the value already is the carrierRequired via value / makeIso
Lifting trait instancesManualBuilt-in helpers (makeEquivalence, makeOrder, makeCombiner, makeReducer)

Choose Brand when you want validated, smart-constructor values that remain interchangeable with the base type. Choose Newtype when you want a strictly opaque wrapper that the compiler refuses to confuse with the raw carrier, and you want to lift Equivalence/Order/Combiner/Reducer instances onto it cheaply.

The tagged interface. Extend it as an interface to declare a newtype with a unique key and a carrier type. The tag is compile-time only — no runtime wrapper is allocated.

import { Newtype } from "effect"
interface UserId extends Newtype.Newtype<"UserId", number> {}
interface OrderId extends Newtype.Newtype<"OrderId", number> {}
// Both wrap `number`, but are not assignable to each other.

The type that matches any newtype. Use it as a generic constraint when a type parameter can be any newtype: <N extends Newtype.Any>.

import { Newtype } from "effect"
// Accepts any newtype as the type parameter
declare function describe<N extends Newtype.Any>(value: N): string
// => N is constrained to `Newtype<any, unknown>`

A type-level helper that extracts the key literal from a newtype.

import { Newtype } from "effect"
interface UserId extends Newtype.Newtype<"UserId", number> {}
type K = Newtype.Key<UserId>
// => "UserId"

A type-level helper that extracts the carrier (underlying) type from a newtype.

import { Newtype } from "effect"
interface UserId extends Newtype.Newtype<"UserId", number> {}
type C = Newtype.Carrier<UserId>
// => number

Creates an Optic.Iso<N, Carrier> for a newtype, giving both wrapping (set) and unwrapping (get). Both directions are identity casts, so there is no runtime cost. Because the result is an Optic.Iso, it composes with other optics.

import { Newtype } from "effect"
interface Label extends Newtype.Newtype<"Label", string> {}
const labelIso = Newtype.makeIso<Label>()
const label: Label = labelIso.set("world")
// => Label wrapping "world"
const str: string = labelIso.get(label)
// => "world"

Unwraps a newtype value, returning the underlying carrier. This is an identity cast (zero runtime cost). Prefer makeIso when you also need to wrap values.

import { Newtype } from "effect"
interface Label extends Newtype.Newtype<"Label", string> {}
const iso = Newtype.makeIso<Label>()
const label = iso.set("hello")
const raw: string = Newtype.value(label)
// => "hello"

Lifts an Equivalence<Carrier> into an Equivalence<Newtype>, so you can compare two newtype values for equality. The result delegates to the carrier equivalence.

import { Equivalence, Newtype } from "effect"
interface Label extends Newtype.Newtype<"Label", string> {}
const eq = Newtype.makeEquivalence<Label>(Equivalence.String)
const iso = Newtype.makeIso<Label>()
eq(iso.set("a"), iso.set("a"))
// => true
eq(iso.set("a"), iso.set("b"))
// => false

See also: Equivalence.

Lifts an Order<Carrier> into an Order<Newtype>, so you can sort or compare newtype values. The result delegates to the carrier order and returns -1, 0, or 1.

import { Newtype, Order } from "effect"
interface Score extends Newtype.Newtype<"Score", number> {}
const ord = Newtype.makeOrder<Score>(Order.Number)
const iso = Newtype.makeIso<Score>()
ord(iso.set(1), iso.set(2))
// => -1
ord(iso.set(2), iso.set(2))
// => 0

See also: Order.

Lifts a Combiner<Carrier> into a Combiner<Newtype>. Use it to merge two newtype values; the result delegates to the carrier combiner’s combine.

import { Combiner, Newtype } from "effect"
interface Amount extends Newtype.Newtype<"Amount", number> {}
const sum = Combiner.make<number>((a, b) => a + b)
const combiner = Newtype.makeCombiner<Amount>(sum)
const iso = Newtype.makeIso<Amount>()
const total = combiner.combine(iso.set(10), iso.set(20))
Newtype.value(total)
// => 30

Lifts a Reducer<Carrier> into a Reducer<Newtype>. A Reducer adds an initialValue and combineAll to a Combiner, so you can fold an entire collection of newtype values.

import { Newtype, Reducer } from "effect"
interface Score extends Newtype.Newtype<"Score", number> {}
const sum = Reducer.make<number>((a, b) => a + b, 0)
const reducer = Newtype.makeReducer<Score>(sum)
const iso = Newtype.makeIso<Score>()
const total = reducer.combineAll([iso.set(1), iso.set(2), iso.set(3)])
Newtype.value(total)
// => 6
// combineAll on an empty collection returns the initialValue (wrapped)
Newtype.value(reducer.combineAll([]))
// => 0
  • Branded Types — the validating, carrier-compatible alternative.
  • Equivalence — building equality instances to lift with makeEquivalence.
  • Order — building ordering instances to lift with makeOrder.