Build Your Own Bedrock · Part 3
Maybe: a null policy in 52 lines
Your compiler contract from Part 2 is signed. Time to lay the first data type of your bedrock, and it should be the smallest one: absence. Core/Data/Maybe.ts is 52 lines, and you can build your version this afternoon. What matters is not the code — it's the two policy decisions it encodes.
Decision 1: don't wrap
// Core/Data/Maybe.ts
/** This is just a sugar syntax for T | null
* but the decoder is better
* */
export type Maybe<T> = T | null If you come from FP, your instinct is an Option class with Some/None variants and a fluent .map(). Bedrock deliberately refuses. With strictNullChecks on, TypeScript already tracks nullability through every branch — a wrapper would rebuild, at runtime cost, a check the compiler does for free. Keeping Maybe<T> as bare T | null means:
- Zero allocation. No box around every optional value in a hot loop or a large API payload.
- Native syntax works.
user?.nameandcount ?? 0operate directly on aMaybe; anOptionclass would fight them. - No interop seam. Every JS API that returns
T | null—Map.get-style lookups, DOM queries, regex matches — is already aMaybe.
So why name the alias at all? Because the name marks intent at every signature — this null is a modelled state, not a bug — and because the name gives the helpers and decoders a home.
Decision 2: one absence, not two
JavaScript ships two nothings, null and undefined, and codebases rot where they mix. Bedrock's policy: null is the only absence in domain code, and undefined gets converted at the door:
// Core/Data/Maybe.ts
export function maybe<T>(value: T | null | undefined): Maybe<T> {
return value == null ? null : value
}
export function mapMaybe<A, B>(m: Maybe<A>, fn: (a: A) => B): Maybe<B> {
return m == null ? null : fn(m)
} Note the loose == null — one of the rare places it's the right tool, because it matches both nothings at once. maybe() is the funnel: anything that might be undefined passes through it and comes out normalized. mapMaybe is the workhorse — transform the value if present, propagate the null if not. That's the entire combinator library. Resist adding more until you feel the need.
The single escape hatch
Sometimes you must assert presence. Bedrock gives that exactly one spelling:
// Core/Data/Maybe.ts
export function throwIfNull<T>(m: Maybe<T>, errorMsg: string): T {
if (m == null) {
throw new Error(errorMsg)
}
return m
} The ! postfix operator is banned by the lint contract, so this function is the only way to turn Maybe<T> into T without handling the null. That's the design: an unsafe read should be a named, greppable event that carries its own error message, not a silent punctuation mark. Run grep -r throwIfNull and you have audited every presence assumption in your codebase. In practice most call sites live inside decoder .transform blocks, where the decoders library catches the throw and turns it into a decode failure — so even the escape hatch usually lands in a net.
Three decoders for three boundaries
The file's comment says it: the type is sugar, "but the decoder is better". Absent data reaches you in three costumes, so the module exports three decoders:
// Core/Data/Maybe.ts
export function maybeDecoder<T>(
valueDecoder: JD.Decoder<T>,
): JD.Decoder<Maybe<T>> {
return JD.nullable(valueDecoder)
}
/** Decodes undefined as null */
export function maybeOptionalDecoder<T>(
valueDecoder: JD.Decoder<T>,
): JD.Decoder<Maybe<T>> {
return JD.optional(maybeDecoder(valueDecoder)).transform((m) => m ?? null)
}
export function stringMaybeDecoder<T>(
valueDecoder: JD.Decoder<T>,
): JD.Decoder<Maybe<T>> {
return JD.string
.transform((s) => s.toLowerCase())
.transform((s) => (s === "null" ? null : valueDecoder.verify(s)))
} maybeDecoder handles honest JSON null. maybeOptionalDecoder handles the key that isn't there at all — it accepts undefined and immediately normalizes it to null, so Decision 2 holds even for missing fields. stringMaybeDecoder handles stringly boundaries like URL params, where absence arrives as the five characters "null". Three costumes in, one Maybe out.
That's the whole file: a policy for absence, a funnel for undefined, one named escape hatch, and a decoder per boundary. Every optional value in the rest of the series stands on it.
In your language
The two decisions port anywhere. Kotlin's T? with ?. and ?: is this design — nullable types tracked by the compiler, no wrapper. Swift's Optional and Rust's Option are the wrapped flavor, with if let and pattern matching standing in for strictNullChecks. Elm and Haskell give you Maybe natively. In Python, type Optional[T] hints plus a strict mypy config get you surprisingly close — and whatever the language, keep the escape hatch a single named function you can grep for.