Type Driven Development: Capabilities

Written by

in

Part 22 – Capabilities

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

Side-effects are a pain. They’re hard to type b/c some don’t return useful values, what “happened” is often outside of the program, & they’re not deterministic; you can’t guarantee they’ll always work or fail. Dependency Injection was invented to fix this for testing purposes; you make a pure stub function take in the side-effect as a dependency, inject a fake thing in the tests, & a real thing at runtime.

type Ship = (db:Database) => ...
// runtime, use pg()
// tests, use mockPg()Code language: JavaScript (javascript)

These mocks/stubs can become painful to test when you start composing functions. Functional Core, Imperative Shell helps ensure the majority of your domain code has no side effects. Haskell created an IO type to indicate “this function has side-effects” so it was easier to read from the type. Elm/Roc removed side-effects from the language, solving this problem entirely.

The rest of the typed world in languages w/side effects needs to know what functions have side-effects to better model & read their code, so borrowed Capabilities. Without them:

function createReport(): Promise<void> {
  const data = await database.query(...)
  await filesystem.writeFile('report.json", data)
  await emailClient.send('cow@moocom, '...')Code language: PHP (php)

createReport has Ambient Authority, meaning it can query a database, read from the file system, & email users; the type tells you NONE of that:

() => Promise<void>Code language: JavaScript (javascript)

You have to read the code. Most code is more abstracted than this, making you read _moar_ code. By requiring side-effects as parameters, it’s clear what the function can do:

type QueryDatabase = ( query: string ) =>
  Promise<unknown[]>
type SendEmail = ( recipient: string, message: string ) =>
  Promise<void>

async function createReport(
  query: QueryDatabase, 
  email: SendEmail): Promise<void> {
    const data = await queryDatabase(...)
    await sendEmail( 
      'moo@cow.com', 
      `Report contains ${data.length} records` 
    ) 
}Code language: JavaScript (javascript)

createReport can query the database & send email because those Capabilities were passed to it, but NOT read the file system. Capabilities make clear what the function does & the reverse is true as well; no Capability passed, no authority to do side-effects. In Unit Tests, those would be pure function stubs. In architecture, side-effects will be moved higher up / away from Domain code. You only pass in the Capabilities your functions need.

You can enforce authority with types, too. Instead of passing an Account & User, then checking if the user can cancel a payment later:

cancelPayment = (u:User, a:Account) => {
  if(u.isPrimary) {
    throw new Error('not authorized')
  }

// cancel paymentCode language: JavaScript (javascript)

Instead, require that Capability in the type signature:

type CancelPayment = (u:PrimaryUser, a:Account) => ...Code language: JavaScript (javascript)

Comments

Leave a Reply

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