I'll Be Backend
Principles · TypeScript

Primitive obsession: introduce domain types

Give a value its own type, so its rules live in one place.

13 steps5 chapters~6 min at 1.25×
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the TypeScript video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–8

Payoff

Steps 9–10

TypeScript bonus

Step 11

Recap

Steps 12–13

The code: before and after

Before

class AccountService {
  private readonly emailById = new Map<string, string>();

  register(customerId: string, email: string): void {
    if (!email.includes("@"))
      throw new Error(email);
    this.emailById.set(customerId, email.toLowerCase());
  }

  changeEmail(customerId: string, newEmail: string): void {
    if (!newEmail.includes("@"))
      throw new Error(newEmail);
    this.emailById.set(customerId, newEmail);
  }

  findCustomer(email: string): string | undefined {
    const wanted = email.toLowerCase();
    for (const [id, stored] of this.emailById) {
      if (stored === wanted) return id;
    }
    return undefined;
  }
}

class SignupController {
  constructor(private readonly accounts: AccountService) {}

  onSignup(email: string, customerId: string): void {
    this.accounts.register(customerId, email);
  }
}

function supportDesk(accounts: AccountService) {
  accounts.register("c1", "ana@shop.com");
  accounts.changeEmail("c1", "Ana@Shop.com");
  return accounts.findCustomer("ana@shop.com");
}

After

class EmailAddress {
  readonly #value: string;

  constructor(value: string) {
    value = value.trim();
    if (!value.includes("@"))
      throw new Error(value);
    if (value.indexOf(".", value.indexOf("@")) < 0)
      throw new Error(value);
    this.#value = value.toLowerCase();
  }

  get value(): string { return this.#value; }

  equals(other: EmailAddress): boolean {
    return this.#value === other.#value;
  }
}

type CustomerId = string & { __brand: "CustomerId" };

function CustomerId(value: string): CustomerId {
  return value as CustomerId;
}

class AccountService {
  private readonly emailById = new Map<CustomerId, EmailAddress>();

  register(customerId: CustomerId, email: EmailAddress): void {
    this.emailById.set(customerId, email);
  }

  changeEmail(customerId: CustomerId, newEmail: EmailAddress): void {
    this.emailById.set(customerId, newEmail);
  }

  findCustomer(email: EmailAddress): CustomerId | undefined {
    for (const [id, stored] of this.emailById) {
      if (stored.equals(email)) return id;
    }
    return undefined;
  }
}

class SignupController {
  constructor(private readonly accounts: AccountService) {}

  onSignup(email: string, customerId: string): void {
    this.accounts.register(CustomerId(customerId),
      new EmailAddress(email));
  }
}

function supportDesk(accounts: AccountService) {
  const c1 = CustomerId("c1");
  accounts.register(c1, new EmailAddress("ana@shop.com"));
  accounts.changeEmail(c1, new EmailAddress("Ana@Shop.com"));
  return accounts.findCustomer(new EmailAddress("ana@shop.com"));
}

Transcript, step by step

Problem

Step 1: Accounts keyed by strings Before

This account service keeps every customer's email in a map, keyed by customer ID. Register checks that the address has an at sign and stores it in lower case, and change email checks it too. Find customer lowercases the address you ask for and returns the ID whose stored email matches it. The signup controller hands a new customer's email and ID to register. And the support desk registers Ana, changes her email, then looks her up by address.

Step 2: Trim it, three times Code smell

Support says customers paste emails with spaces around them, so trim them, and reject an address with no dot after the at sign. Primitive obsession is using a plain string for an idea that has its own rules, so every method that touches the string must remember them. You can see it here in register, here in change email, and here in find customer. Each of them had to learn the new rule, and the next rule about emails will cost the same three edits.

Step 3: Already drifted Code smell

And the copies have already drifted, because change email stores the address without lowercasing it. So when the support desk changes Ana's email to a capitalized address, the lookup in lower case returns undefined. The customer ID and the email are both strings, so the compiler accepts them in either order. Look at the signup call, which takes the email first and hands it to register, which wants the ID first. Swap them by mistake, and nothing fails until a lookup returns the wrong customer.

Step 4: Back it out, then add types Plan

Let's back that change out. First the email and the customer ID get types of their own, so the email rule has one home, and then the trim and the dot check go in there.

Refactor

Step 5: The email gets a type Replace Primitive with Object

We start with the email, as a class called Email Address that holds one string. The string sits in a private field, and a getter called value lets other code read it. Register, change email and find customer take an Email Address now, and the map stores them too. The checks stay where they were for now, and they read the string through value. The signup controller and the support desk wrap their strings at the call, so the program does the same as before.

Step 6: The @ check moves in Move Statements into Function

The at sign check was the same in both methods, so it moves into the constructor. Register and change email lose their checks, and an address without an at sign can't even be built. One more thing changes here, because a lookup for an invalid address like bob used to return undefined, and now it throws when its Email Address is built.

Step 7: Lowercase moves in Move Statements into Function

Lowercasing is the other half of the email rule, so it moves into the constructor too. Register stores the address it's given, and find customer asks each stored address whether it equals the one you passed. Triple equals on two objects only checks that they're the same object, so Email Address gets an equals method that compares the values. Two addresses with the same text are now equal, and that makes Email Address a value object. This step changes one behavior on purpose, because change email now stores a lowercased address too, and that fixes the drift. So the support desk's lookup finds Ana's ID again.

Step 8: The ID gets a type Replace Primitive with Object

The customer ID gets the same treatment, as a class called Customer ID. Its field is private too, and that matters here, because TypeScript compares classes by shape, so two wrappers with a public value would be interchangeable. Register, change email and the result of find customer all use it. The map keeps the ID's string as its key, because a map compares object keys by identity, and two Customer IDs for c1 would be two entries. The signup controller builds both types, and the support desk names its customer once. Now if you swap the two arguments, the code doesn't compile, because an Email Address is not a Customer ID.

Payoff

Step 9: Trim and dot, one edit Payoff

Now the trim and the dot rule go back in. The constructor trims the value first, then checks for an at sign and a dot after it, and lowercases last. The account service and the signup controller didn't change. Register, change email and find customer hold no email rule now, and the support desk finds Ana's ID again.

Step 10: Why the types paid off Payoff

The email rule used to live in three methods and had already drifted, so now one constructor owns it. A method can't forget the rule, because every Email Address it receives went through that constructor. And the customer ID and the email can't trade places, because the compiler knows which one is which.

TypeScript bonus

Step 11: An ID without the class Branded type

Customer ID was a whole class with a private field and a getter, and the map had to unwrap it into a string for its key. A branded type removes that weight, because it's a plain string at run time that the compiler still treats as a type of its own. The function with the same name is the one place that makes an ID, and it holds the only cast in the file. So the map keys on Customer ID directly, and find customer returns the key it found. This fits a value with no rules of its own, like this ID, and Email Address keeps its class because its constructor holds the email rule. Swapping the two arguments in register still fails to compile.

Recap

Step 12: Where we started, where we ended Before / after

On the left, every method took two strings, and the customer ID and the email could trade places. The at sign check was written twice, and the lowercase was in register and find customer but missing from change email. On the right, Email Address owns the whole rule in its constructor, and Customer ID gives the ID a type of its own. The account service only stores and looks up addresses now.

Step 13: When to reach for it Takeaways

Reach for a domain type when a value has rules that more than one method checks, or when two values of the same type are easy to swap. Skip it for a string with no rules that passes through once, like a delivery note, because a wrapper adds a type and gives you nothing back. You'll spot it by the same check repeated across methods, several strings in one signature, and names that carry the format. You already use one in TypeScript, because URL refuses a malformed address and hands you the host in lower case.