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 bad code impossible to write.
In this article, we'll explore how to use types as part of the design process in TypeScript, enhanced by the Effect library. The careful use of types can make your design more transparent and improve correctness at the same time.
We'll focus on the "micro level" of design working at the lowest level of individual types and functions. While many of these concepts are possible in plain TypeScript, Effect's functional primitives make this kind of refactoring more natural and powerful. We'll let the type system and Effect guide us toward better solutions.
Sometimes, the best code is the code that simply cannot compile if it's wrong.
The Starting Point
Let's work with a common example: a Contact type. Here's what a typical implementation might look like:
type Contact = {
firstName: string
middleInitial: string
lastName: string
emailAddress: string
// true if ownership of email address is confirmed
isEmailVerified: boolean
address1: string
address2: string
city: string
state: string
zip: string
// true if validated against address service
isAddressValid: boolean
}
This looks straightforward, we've all seen something like this countless times. But how can we refactor this to make better use of the type system?
Understanding Data Relationships
The first step is to analyze how the data is accessed and updated. For instance, would you ever update zip without also updating address1? Probably not. On the other hand, you might frequently update emailAddress without touching firstName.
This leads to our first principle:
Use objects to group together data that must be consistent (atomic), but don't needlessly group unrelated data. In general, low coupling and high cohesion apply across all levels, from individual types and functions to the overall architecture.
In our Contact example, we can identify several natural groupings:
The three name values form a cohesive set
The address values belong together
The email information is its own distinct set
We also have validation flags like isAddressValid and isEmailVerified. Should these be part of their related sets? Yes, because they're dependent on those values. If the emailAddress changes, isEmailVerified should probably reset to false at the same time.
Refactoring with Better Structure
Let's break down our monolithic Contact type. For the postal address, we can create two types: a generic PostalAddress and a context-specific PostalContactInfo that includes validation state.
class PostalAddress extends Schema.Class<PostalAddress>("PostalAddress")({
address1: Schema.String,
address2: Schema.String,
city: Schema.String,
state: Schema.String,
zip: Schema.String,
}) {}
class PostalContactInfo extends Schema.Class<PostalContactInfo>("PostalContactInfo")({
address: PostalAddress,
isAddressValid: Schema.Boolean,
}) {}
Using Effect's Schema.Class provides structural equality and immutability out of the box. Two important properties for domain modeling.
Expressing Optionality with Effect's Option
In the original design, middleInitial is a string, but not everyone has a middle initial. Using an empty string to represent "no value" is a common pattern, but it's implicit and error-prone. Effect provides the Option type to explicitly signal optionality:
class PersonalName extends Schema.Class<PersonalName>("PersonalName")({
firstName: Schema.String,
middleInitial: Schema.optionalWith(Schema.String, { as: "Option" }),
lastName: Schema.String,
}) {}
With optionalWith, we make it impossible to forget that a value might be absent. The type system forces us to handle both cases.
The Complete Refactored Design
Here's our fully refactored Contact type:
import { Option, Schema } from "effect"
class PostalAddress extends Schema.Class<PostalAddress>("PostalAddress")({
address1: Schema.String,
address2: Schema.String,
city: Schema.String,
state: Schema.String,
zip: Schema.String,
}) {}
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,
isEmailVerified: Schema.Boolean,
}) {}
class Contact extends Schema.Class<Contact>("Contact")({
name: PersonalName,
emailContactInfo: EmailContactInfo,
postalContactInfo: PostalContactInfo,
}) {}
Creating Instances
With Effect's Data classes, creating instances is clean and benefits from structural equality:
const contact = new Contact({
name: new PersonalName({
firstName: "John",
middleInitial: Option.some("Q"),
lastName: "Doe",
}),
emailContactInfo: new EmailContactInfo({
emailAddress: "john@example.com",
isEmailVerified: false,
}),
postalContactInfo: new PostalContactInfo({
address: new PostalAddress({
address1: "123 Main St",
address2: "Apt 4B",
city: "Springfield",
state: "IL",
zip: "62701",
}),
isAddressValid: false,
}),
})
Working with Optional Values
Effect provides a rich API for working with Option types:
import { pipe } from "effect"
const displayName = pipe(
contact.name.middleInitial,
Option.match({
onNone: () => `${contact.name.firstName} ${contact.name.lastName}`,
onSome: (initial) => `${contact.name.firstName} ${initial}. ${contact.name.lastName}`,
})
)
const initial = pipe(
contact.name.middleInitial,
Option.getOrElse(() => "")
)
The Benefits
We haven't written a single business logic function yet, but our code already better represents the domain. The refactored design gives us:
Explicit relationships: Related data is grouped together, making dependencies clear
Type safety: The compiler prevents us from forgetting to handle optional values
Immutability: Effect's Data classes are immutable by default, preventing accidental mutations
Structural equality: Two contacts with the same values are considered equal
Better documentation: The type structure itself documents the domain logic
This is just the beginning. In the next steps, we could:
Add branded types to prevent mixing up similar primitives (like ensuring an email string is actually validated)
Use Effect Schema for runtime validation
Create smart constructors that enforce business rules
Leverage Effect's error handling for validation failures
Conclusion
By thinking carefully about our types, we can encode domain knowledge directly into our code structure. TypeScript's type system, enhanced by Effect's functional primitives, gives us powerful tools to make illegal states unrepresentable and make our intentions explicit.
The key is to let the types and schema guide your design. When you find yourself writing comments to explain constraints or relationships, consider whether you could encode that information in the type system instead. Your future self, and your teammates, will thank you.
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.




