Skip to main content

Command Palette

Search for a command to run...

Designing with Types: Wrapper types

Adding meaning to primitive types in TypeScript

Updated
6 min read
Designing with Types: Wrapper types
A
Passionate about socio-technical architecture, defensive design and simple boring sustainable λ code.

At some point in your TypeScript journey, you've probably written code like this:

email: string
zipCode: string
stateCode: string

These fields are all defined as simple strings. But are they really just strings? Can you accidentally swap an email address with a zip code? In a type-safe world, this should be impossible.

In domain-driven design, an email address and a zip code are distinct concepts, not interchangeable strings. We want separate types so they can't be mixed up by mistake.

This has been known as good practice for years, but in many languages, creating hundreds of tiny wrapper types feels painful. This leads to "primitive obsession". The code smell where developers use primitive types everywhere instead of creating meaningful domain types.

With TypeScript and Effect, we have no excuse! It's straightforward to create these wrapper types, and Effect gives us powerful tools for validation and error handling.

Creating Wrapper Types with Branded Types

The simplest way to create a distinct type is to use TypeScript's branded types pattern. Effect provides built-in support for this through Brand:

import { Brand } from "effect"

type EmailAddress = string & Brand.Brand<"EmailAddress">
type ZipCode = string & Brand.Brand<"ZipCode">
type StateCode = string & Brand.Brand<"StateCode">

These are still strings at runtime, but TypeScript treats them as incompatible types at compile time. You can't accidentally pass a ZipCode where an EmailAddress is expected.

Simple Cases with Brand.nominal

If you don't need validation and just want to distinguish types at compile-time, use Brand.nominal:

const UserId = Brand.nominal<UserId>()
const ProductId = Brand.nominal<ProductId>()

function getUser(id: UserId) {
    /* ... */
}
function getProduct(id: ProductId) {
    /* ... */
}

const userId = UserId(42)
const productId = ProductId(42)

getUser(productId) // ❌ Type error
getUser(userId) // ✅ OK

The nominal constructor doesn't do any runtime checks. It just adds the type brand. Use this when you want type safety without validation overhead.

It's also possible to use Brands as part the Schema:

class User extends Schema.Class<User>("User")({
    userId: Schema.String.pipe(Schema.brand("UserId")),
    name: Schema.String,
}) {}

Adding Validation with Brand.refined

For types that need validation (smart constructors), Effect's Brand.refined function lets you create branded types with built-in validation:

The refined function takes two parameters:

  1. A predicate function that returns true if the value is valid

  2. An error function that creates a BrandErrors when validation fails

Let's create refined constructors for our domain types:

type EmailAddress = string & Brand.Brand<"EmailAddress">

const EmailAddress = Brand.refined<EmailAddress>(
    (value) => /^\S+@\S+\.\S+$/.test(value),
    (value) => Brand.error(`"${value}" is not a valid email address`)
)

Notice how validation is defined through predicate functions. Once you have an EmailAddress instance, you know it's valid. The type system guarantees it.

Validation Constructors

The very basic usage is to call the constructor directly. This will throw an exception if validation fails.

EmailAddress("john@example.com")

But it is better to handle it gracefully. Effect provides several approaches, each with different trade-offs.

Sometimes you don't care about the specific error, just whether it succeeded.
The advantage here is simplicity. The disadvantage is losing error details. You don't know why validation failed.
It's usually best for numeric types or simple cases.

EmailAddress.option("john@example.com")

For detailed error information without exceptions, use either.
This gives you both success and failure information in a type-safe way.

EmailAddress.either("john@example.com")

Encapsulation and Type Safety

The beauty of branded types is that TypeScript prevents you from accidentally creating them without validation:

// ❌ This won't compile
const email: EmailAddress = "not-validated@example.com"

// ✅ Must go through the constructor
const email = EmailAddress("validated@example.com")

This ensures that invalid data can never enter your domain, even by accident.

When to Wrap and Unwrap

You should create these wrapped types at service boundaries:

  • When wrapping: At the UI layer (form submissions), when loading from databases, or when receiving data from external APIs

      // external source
      const email = yield* EmailAddress.either(formInput.email)
      // trusted source
      const email = EmailAddress(formInput.email)
    
  • When unwrapping: When persisting to databases, binding to UI elements, or sending data to external services

      const emailDto: string = email // of type EmailAddress
    

The key insight is that once data enters your domain as a wrapped type, it stays wrapped. You rarely need to "unwrap" it within your business logic. You can use it directly since it's still a string/number/etc. at runtime.

A Complete Example with Multiple Types

Let's refactor our contact system using these patterns:

import { Brand, Effect, Schema } from "effect"

type EmailAddress = string & Brand.Brand<"EmailAddress">
type ZipCode = string & Brand.Brand<"ZipCode">
type StateCode = string & Brand.Brand<"StateCode">

const EmailAddress = Brand.refined<EmailAddress>(
    (value) => /^\S+@\S+\.\S+$/.test(value),
    (value) => Brand.error(`"${value}" is not a valid email address`)
)

const ZipCode = Brand.refined<ZipCode>(
    (value) => /^\d{5}$/.test(value),
    (_) => Brand.error(`Zip code must be 5 digits`)
)

const StateCode = Brand.refined<StateCode>(
    (value) => {
        const normalized = value.toUpperCase()
        return ["AZ", "CA", "NY", "TX", "FL"].includes(normalized)
    },
    (_) => Brand.error(`State code is not in list`)
)

class PostalAddress extends Schema.Class<PostalAddress>("PostalAddress")({
    address1: Schema.String,
    address2: Schema.String,
    city: Schema.String,
    state: Schema.String.pipe(Schema.fromBrand(StateCode)),
    zip: Schema.String.pipe(Schema.fromBrand(ZipCode)),
}) {}

class PostalContactInfo extends Schema.Class<PostalContactInfo>("PostalContactInfo")({
    address: PostalAddress,
    isAddressValid: Schema.Boolean,
}) {}

class PersonalName extends Schema.Class<PersonalName>("PersonalName")({
    firstName: Schema.String,
    middleInitial: Schema.optionalWith(Schema.String, { as: "Option" }),
    lastName: Schema.String,
}) {}

class EmailContactInfo extends Schema.Class<EmailContactInfo>("EmailContactInfo")({
    emailAddress: Schema.String.pipe(Schema.fromBrand(EmailAddress)),
    isEmailVerified: Schema.Boolean,
}) {}

class Contact extends Schema.Class<Contact>("Contact")({
    name: PersonalName,
    emailContactInfo: EmailContactInfo,
    postalContactInfo: PostalContactInfo,
}) {}

function createPostalAddress(model: {
    address1: string
    address2: string
    city: string
    state: string
    zip: string
}): Effect.Effect<PostalAddress, Brand.Brand.BrandErrors, never> {
    return Effect.gen(function* () {
        const state = yield* StateCode.either(model.state)
        const zip = yield* ZipCode.either(model.zip)
        return new PostalAddress({
            address1: model.address1,
            address2: model.address2,
            city: model.city,
            state,
            zip,
        })
    })
}

Guidelines Summary

To wrap up, here are the key principles:

  • Use branded types to represent your domain accurately. Don't settle for primitive obsession.

  • Validate at construction time with Brand.refined. Once created, branded types should always be valid.

  • Use Brand.nominal for simple distinction without validation. When you just need compile-time type safety.

  • Be explicit about validation failures. Use Effect's error handling to force callers to handle invalid cases.

  • Leverage Effect's powerful abstractions. Brand, Schema, Either, and Option give you tools that make this pattern practical.

  • Wrap at boundaries, keep wrapped throughout your domain. Validate once at the edge, trust the types internally.

Conclusion

Creating wrapper types with branded types might seem like extra work upfront, but it pays dividends:

  • Compile-time safety: Catch bugs before runtime

  • Self-documenting code: Types express domain concepts clearly

  • Validation guarantees: Invalid data can't enter your domain

  • Better refactoring: The compiler helps you when types change

  • Zero runtime overhead: Brands are erased at runtime

There's no excuse for primitive obsession anymore!


This article was inspired by Scott Wlaschin's excellent "Designing with types" series on F# for Fun and Profit, adapted for the TypeScript and Effect ecosystem.

Designing with Types: A TypeScript + Effect Approach

Part 2 of 3

This series is inspired by Scott Wlaschin's excellent "Designing with types" series from F# for Fun and Profit. We'll explore the same ideas using TypeScript and Effect. Let's begin this journey of using Effect as part of the design process.

Up next

Designing with Types: Introduction

When we write code, we often think about types as just a way to avoid errors or make our IDE or TypeScript happy. But types can do much more than that. They can help us think about our problems, express our business rules, model our domain, and make ...