Type Driven Development: Opaque Types

Written by

in

Part 16 – Opaque 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.

Parts
  1. Part 1 – Branded Types
  2. Part 2 – Product Types
  3. Part 3 – Union & Discriminated Unions
  4. Part 4 – Non-Empty Collections
  5. Part 5 – Indexed Types
  6. Part 6 – unknown vs any
  7. Part 7 – Result
  8. Part 8 – Schema
  9. Part 9 – Total Function
  10. Part 10 – Errors as Values
  11. Part 11 – Property Tests
  12. Part 12 – Type Proofs
  13. Part 13 – Exhaustiveness Checking
  14. Part 14 – Parse, Don’t Validate
  15. Part 15 – Anti-Corruption Layer
  16. Part 16 – Opaque Types
  17. Part 17 – Maybe
  18. Part 18 – Smart Constructors
  19. Part 19 – Pipeline
  20. Part 20 – Railway Oriented Programming
  21. Part 21 – Typestate
  22. Part 22 – Capabilities
  23. Part 23 – Immutability
  24. Part 24 – Making Impossible States Impossible
  25. Part 25 – Type Driven Development: How to do it
  26. Part 26 – Final Thoughts

When you call a function that can fail in programming, you’d return a Result type. Types are similiar; some types can only be created in the happy path, so you either return the Type or not.

type MakeEmail = (userTypedStuff:string) => Email | undefinedCode language: JavaScript (javascript)

While gradually typed languages let the author break the rules a little bit, such as creating Branded Types yourself, we’ll assume you want to leverage your hard work & keep the types safe.

They’re called “Opaque” because you see the type, but can’t see through them (e.g. not transparent) on their underlying structure, nor how to make them. It’s a blanket term that can cover many types like Branded & Phantom, but you could apply these rules to safely created Product & Discriminated Unions too.

For example, if we create a module and export a Branded type that uses a read only brand property name & string email value:

export type Email = string & {
  readonly brand: "Email"
}Code language: JavaScript (javascript)

You could then outside of the module create an invalid version of it:

// not a valid email address
const myEmail:Email = "🐮" as EmailCode language: JavaScript (javascript)

Given validation combined with various type guards & type narrowing, better give the developer a safe way to construct those types. We use a symbol that isn’t exported so the developer can’t do that, then provide them a function:

// not exported
declare const EmailBrand: unique symbol

// exported so developer can use type, but not create it
export type Email = string & {
  readonly [EmailBrand]: "Email"
}

// how the developer makes an Email type
export function makeEmail(value: string): Email | undefined {
  return value.includes("@")
    ? (value as Email)
    : undefinedCode language: JavaScript (javascript)

Notice both the type and function are exported, just not the symbol, so you can _use_ the type via “import type” and typing things, but you can’t create it unless you possess a valid email string so you can call the makeEmail function and get a valid Email type back.

import { type Email, makeEmail } from "./email"

const a: Email = "not-an-email"
// ❌ Type error

const b = makeEmail("cow@moo . com")
// ✅ Email

const c = makeEmail("🐮")
// ✅ undefinedCode language: JavaScript (javascript)

Anytime a type needs to be validated before creation, such as Email, Money, Valid JSON, an Access Token, and you’d think of creating a function that returns Thing | undefined or Result<Thing, Error>, it might be a good candidate for an Opaque type. It abstracts away Parse, Don’t Validate, and ensures the developer can’t accidentally create an invalid type. You can then blast these creation functions with Property Tests.

Comments

Leave a Reply

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