> Amin Khansari's Notes_

Designing with Types: Introduction

How to use TypeScript's type system and Effect's functional primitives to model domains more accurately and make bad code impossible to write.

Pixel art cover illustration introducing type-driven design

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.

SERIES: Continue Reading

SEARCH POSTS

START TYPING TO SEARCH_