Type Driven Development: Smart Constructors

Written by

in

Part 18 – Smart Constructors

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

In development, especially in Gradually Typed languages, you’ll have to integrate w/degrees of unsafe code. Code below has a type signature that lies on multiple levels:

function unsafeParse(s:string):MyType {
  return JSON.parse(s) as MyType
}Code language: JavaScript (javascript)

Doesn’t indicate it could fail, nor that it does an unsafe type narrowing. You could fix, but you’ll find w/larger applications & libraries, you can’t fix all the things. Example above has 1 line of code, but imagine if it’s a large, battle-tested class that is 2 years old, or exported from some 3rd party library. You should aim to work in small batches.

This is where Smart Constructors come in. It’s not “class constructor” but rather “make a thing; I construct something, but it might fail”.

Smart Constructors allow you to wrap unsafe code to make things you need in a safe way:

type MakeMyType = (s:string) => Result<MyType, JSONError | ZodError>

Your function wraps the unsafe code to make it safe, fixes the type.

function makeMyType(s:string):Result<MyType, JSONError | ZodError> {

// we assume they are fine throwing random errors

// so we protect ourselves

try {

// first capture the JSON

const json = unsafeParse(s)

// next attempt to decode to our type using a Schema

const { success, data, error } = myTypeSchema.safeParse(json)

// if it works, happy path achieved

if(success) {

return ok(data)

// else, JSON worked, but Zod found

// it wasn’t our type

} else {

return err(ZodError(error))

}

// otherwise, JSON.parse failed

} catch(boomErr) {

return err(JSONError(boomErr))

}

}

This is such a common pattern, libraries like NeverThrow have functions like fromThrowable for both sync and async code.

const JSONError = (err:unknown) => ({ tag: ‘JSONError’, err })

const safeJSONParse = Result.fromThrowable(

unsafeParse,

JSONError

)

Effect-ts has various ways to differentiate, integrate, and combine errors.

Before good type systems, you’d rely on runtime exceptions where your code design didn’t indicate intent. Examples were OOP Singletons that would throw an exception in the constructor to say to the dev “Hey, you’re using it wrong, do this instead”. Dev didn’t know how from the class design, nor the docs/comments, nor the compiler, so you had to crash the program to get the point across.

Thankfully, you now have types to communicate intent & ergonomics. When integrating with code that is not safe by throwing exceptions knowingly / unknowingly, doing unsafe / no type narrowing, or other dangerous things like side-effects & mutations, Smart Constructors allows you to quickly integrate large swaths of useful but dangerous code in a type-safe way.

Comments

Leave a Reply

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