Skip to content

Newtypes

A newtype creates a distinct type from another with no runtime cost. The compiler refuses to mix UserId(int) with OrderId(int) even though both are int underneath — the type is the contract.

Distinct IDs, Email with helpers, units of measure, and a factory with validation.

11-newtypes-rich.zolo
Playground
// Feature: "Rich" newtypes — opaque wrappers with helpers

// Syntax: `newtype X(T)`; create with `X.new(v)`; extract with `.unwrap()`;

// add behavior via top-level functions that take the

// newtype.

// When to use: strong domain types (Email, UserId, Money) that

// the typechecker refuses to mix with the underlying type.


use std::String

newtype UserId(int)
newtype OrderId(int)

// Distinct IDs: typechecker refuses to swap the order.

fn buy(user: UserId, order: OrderId) {
  print("user {user.unwrap()} bought order {order.unwrap()}")
}

let u = UserId.new(7)
let o = OrderId.new(99)
buy(u, o)

// buy(o, u)  // ERROR at compile-time: incompatible types


// Newtype with helpers — top-level functions that take the newtype.

newtype Email(str)

fn email_local(e: Email) -> str {
  let parts = e.unwrap().split("@")
  return parts[0]
}

fn email_domain(e: Email) -> str {
  let parts = e.unwrap().split("@")
  return parts[1]
}

let e = Email.new("[email protected]")
print(e.unwrap())  // [email protected]

print(email_local(e))  // alice

print(email_domain(e))  // zolo.dev


// Newtype for "units" — distinguishes Meters vs Feet.

newtype Meters(float)
newtype Feet(float)

fn meters_to_feet(m: Meters) -> Feet {
  return Feet.new(m.unwrap() * 3.28084)
}

fn feet_to_meters(f: Feet) -> Meters {
  return Meters.new(f.unwrap() / 3.28084)
}

let m = Meters.new(100.0)
let f = meters_to_feet(m)
print(f.unwrap())  // ~328.084


let back = feet_to_meters(f)
print(back.unwrap())  // ~100.0


// Factory with light validation — enforces an invariant.

fn make_user_id(n: int) -> UserId {
  if n < 0 {
    return UserId.new(0)
  }
  return UserId.new(n)
}

let safe = make_user_id(-3)
print(safe.unwrap())  // 0


let ok = make_user_id(42)
print(ok.unwrap())  // 42

Best practices:

  • Create the value with Type.new(v) and extract it with .unwrap().
  • To add behaviour, write top-level functions that receive the newtype — fn email_local(e: Email) -> str { ... } — rather than relying on impl over a newtype, which is not stable.
  • Use newtypes for IDs, units (Metres, Feet, Degrees), and any primitive value that the domain forbids from being swapped accidentally.

See also

enespt-br