Skip to main content

Command Palette

Search for a command to run...

Designing with Types: Introduction

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

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:

  1. Explicit relationships: Related data is grouped together, making dependencies clear

  2. Type safety: The compiler prevents us from forgetting to handle optional values

  3. Immutability: Effect's Data classes are immutable by default, preventing accidental mutations

  4. Structural equality: Two contacts with the same values are considered equal

  5. 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.