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