Type Driven Development: Product Types

Written by

in

Part 2 – Product Types

This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.

Previous Posts:

  1. Part 1 – Branded Types

Once we’ve identified “things that are separate” using Branded types, the next question we often ask is “what things belong together?”

Sometimes they reveal themselves as you write code, often called “Domain Discovery”, like in this example:

createUser(
  "Jesse",
  "Warden",
  "jesse @ gmail . com",
  true,
  false
)Code language: JavaScript (javascript)

What is the true and false for? No clue, you’d have to read the code. Using a Product Type, also called a Record or Object (OOP devs would create a class and call it a Data Transfer Object / DTO or Value Object / VO if it had behavior), we can fix this problem:

createUser({
  firstName: "Jesse",
  lastName: "Warden",
  email: "jesse @ gmail . com",
  isAdmin: true,
  sendWelcomeEmail: false
})Code language: CSS (css)

It’s self-documenting. You also don’t have positional parameter problems with the mystery booleans (aka boolean blindness); instead of having to pass 5 Branded type parameters, now you just pass 1. This helps solve Primitive Obsession even if you want to use primitives in your Product type starting out.

This also allows you to evolve the User Product type without breaking the call sites; e.g. everywhere createUser is used. If we add a Locale to the User, imagine every createUser starting like:

createUser(email)

Then you add locale:

createUser(user, locale)

Easy change, but still lots of changes in your pull request or commits. If you just do createUser(user), only the Product type needs to change. You have flexibility to grow, especially combined with Partial/Omit/Pick types. As you do Cohesion Discovery (what additional things belong together), Product Types compose really well (e.g. putting Objects inside of Objects):

type Address = {
  street: Street
  city: City
}

type Person = {
  name: Name
  address: Address
}

This grouping helps a lot in Dependency Injection. As your app grows, you’ll have a ton:

function processOrder(
  db,
  logger,
  emailService,
  paymentService,
  order
)Code language: JavaScript (javascript)

While explicit, it’s still hard to read & remember. Creating a Product type, you can change it to:

type Dependencies = {
  db: Database
  logger: Logger
  emailService: EmailService
  paymentService: PaymentService
}

processOrder(deps, order)

Testing is easier, too, because you just create a test fixture once, then update the properties you need to test a new variant:

const validOrder = {
  …defaultOrder,
  country: canada
}Code language: JavaScript (javascript)

Finally, they form the basis of further composition in Making Impossible States Impossible. They help enforce invariants (e.g. business rules in the type system) so you don’t need functions/class methods and unit tests to enforce it; instead the compiler does. Think what data belongs together and know you’ll discover more as you code.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *