Designing with Types: Wrapper types
Adding meaning to primitive types in TypeScript

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:
A predicate function that returns true if the value is valid
An error function that creates a
BrandErrorswhen 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.nominalfor 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.



