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.
Defining a newtype and an iso
Section titled “Defining a newtype and an iso”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 / unwrappingconst labelIso = Newtype.makeIso<Label>()
// 3. Wrap a raw string into the newtypeconst myLabel: Label = labelIso.set("hello")
// 4. Unwrap back to the carrierconst 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.
Unwrapping only
Section titled “Unwrapping only”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"Keys must be unique
Section titled “Keys must be unique”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'Newtype vs Brand
Section titled “Newtype vs Brand”Both Brand and Newtype give a primitive a distinct identity, but they make different tradeoffs:
Brand | Newtype | |
|---|---|---|
| Relationship to carrier | Intersection: number & Brand<"Id"> — still usable as a number | Opaque: not assignable to the carrier without unwrapping |
| Runtime validation | Yes — Brand.make / Brand.check can validate and reject values (Brand.nominal skips it) | No — purely structural, no validation |
| Unwrapping | Not needed; the value already is the carrier | Required via value / makeIso |
| Lifting trait instances | Manual | Built-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.
Reference
Section titled “Reference”Newtype.Newtype
Section titled “Newtype.Newtype”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.Newtype.Any
Section titled “Newtype.Any”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 parameterdeclare function describe<N extends Newtype.Any>(value: N): string// => N is constrained to `Newtype<any, unknown>`Newtype.Key
Section titled “Newtype.Key”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"Newtype.Carrier
Section titled “Newtype.Carrier”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>// => numberNewtype.makeIso
Section titled “Newtype.makeIso”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"Newtype.value
Section titled “Newtype.value”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"Newtype.makeEquivalence
Section titled “Newtype.makeEquivalence”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"))// => falseSee also: Equivalence.
Newtype.makeOrder
Section titled “Newtype.makeOrder”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))// => 0See also: Order.
Newtype.makeCombiner
Section titled “Newtype.makeCombiner”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)// => 30Newtype.makeReducer
Section titled “Newtype.makeReducer”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([]))// => 0See also
Section titled “See also”- Branded Types — the validating, carrier-compatible alternative.
- Equivalence — building equality instances to lift with
makeEquivalence. - Order — building ordering instances to lift with
makeOrder.