Tactical DDD building blocks and the functional primitives they stand on — Brand, Value Object, Entity, Factory, over Maybe and Result.
A ground type is a type with no free type variables: fully concrete, standing on nothing further. That is what this library is for — the groundwork you lay before modelling a domain.
Every term used here is defined in CONTEXT.md.
pnpm add github:lhellemons/ground-types#semver:^0.3.0Not published to npm yet.
Each module is a separate subpath export.
| Subpath | Exports |
|---|---|
/maybe |
Maybe, Just, Nothing, maybe, just, nothing, fromNullable, isJust, isNothing, orElse, fallback, map, andThen, assertJust, fromResult |
/result |
Result, Success, Failure, ThrownError, NotAResult, NotAPromise, result, success, failure, isSuccess, isFailure, tryCatch, assertSuccess, map, mapError, fallback, orElse, andThen, fromMaybe |
/brand |
Brand, Branded |
/value-object |
Primitive, PrimitiveValueObject, definePrimitiveValueObject |
/domain |
Entity, CompoundValueObject, DTOSource, DomainObjectFactory |
/intern-registry |
InternRegistry, internByKey |
/fn |
Fn, Mapper, CurryableMapper, compose, pipe, curry, identity, constant |
/promise |
AbortablePromise, AbortContext, RejectionError, resultify, fail, recoverWith, State with its constructors and guards, settledResult, stateOf, TrackedState |
/promise/fake |
fakePromise, fakeAbortablePromise |
/call |
Call, AsyncCall, AbortableCall, abortable, resultify |
/abort |
AbortError, isAbortError, ABORT_ERROR_NAME |
import { andThen, isFailure } from '@lhellemons/ground-types/result'
import type { Branded } from '@lhellemons/ground-types/brand'
import { definePrimitiveValueObject } from '@lhellemons/ground-types/value-object'Modules share a name deliberately: map in /maybe and map in
/result are the same idea over different containers, and so are
resultify in /promise and resultify in /call. Import per module
rather than flattening them into one namespace.
/promise/fake is a separate subpath rather than part of /promise so
that test doubles cannot reach a production bundle by accident.
There is also a root entry point, for a bare
import from '@lhellemons/ground-types' and for tooling that predates or
ignores the exports map. It re-exports each module as a namespace, named
after its subpath (intern-registry becomes internRegistry,
value-object becomes valueObject, the rest unchanged), so maybe.map
and result.map stay distinct here too — nothing is flattened. Per-module
import remains the recommended style, for the same reason as above;
/promise/fake is not part of the root, for the same reason as above.
import { maybe, result } from '@lhellemons/ground-types'
result.map(...)
maybe.map(...)Given only its configuration, every maybe/result combinator returns a
unary function, so a chain of them nests unless something unwinds it.
pipe, from /fn, runs a value through a sequence of steps left to
right, in the order the data actually flows:
import { pipe } from '@lhellemons/ground-types/fn'
import { andThen, map, maybe, orElse } from '@lhellemons/ground-types/maybe'
const label = pipe(maybe(x), map(double), andThen(validate), orElse(0))
// instead of orElse(0)(andThen(validate)(map(double)(maybe(x))))pipe always applies immediately — there is no deferred, build-a-function
form. Each step's parameter type is pinned to the previous step's return
type, up to ten steps; a step that doesn't fit its neighbour is a compile
error at that step. For building a reusable function with no value in
hand yet, use compose, which reads right to left instead and is typed
the same way, up to ten Mappers:
import { compose } from '@lhellemons/ground-types/fn'
const shoutLabel = compose(shout, stringify, double)
shoutLabel(21) // same as shout(stringify(double(21)))See docs/adr/0004-pipe-value-first.md
for why pipe is shaped this way.
There is no mapAsync, no AsyncResult, and no second combinator set for
asynchronous code. There does not need to be: every Result combinator,
given only its configuration, returns a unary function, so .then already
composes with them.
import { fail, resultify } from '@lhellemons/ground-types/promise'
import { map, orElse } from '@lhellemons/ground-types/result'
const label = await resultify(fail, fetchWidgetCode())
.then(map((code: number) => `widget-${code}`))
.then(orElse('no widget'))resultify turns a promise that may reject into one that always resolves
with a Result; map and orElse are the same functions you would use
on a synchronous Result. The same holds for andThen, fallback, and
the Maybe combinators.
Going the other way, State bridges back:
import { settledResult, stateOf } from '@lhellemons/ground-types/promise'
const tracked = stateOf(fetchWidget())
settledResult(tracked.current) // Nothing while pending, a Result once settledNode 20 or newer, and a TypeScript lib that includes DOM or
@types/node. /promise and /abort are built on AbortController,
AbortSignal and DOMException, which are WHATWG platform standards
rather than document APIs — present in browsers, Node 20+, Deno and Bun
alike — but they are not in lib.es2022, and they appear in this
library's emitted declarations. A consumer whose own lib is
["ES2022"] alone will not be able to resolve them.
Maybe and Result are unboxed. A Just<T> is the T, a
Nothing is undefined, a Success<T> is the T, and a
Failure is the Error — discriminated at runtime by
instanceof Error, with no wrapper object allocated.
This makes them nearly free to pass across a boundary, and it means a
value that is already a T needs no unwrapping at the point of use. The
cost is that nesting is unrepresentable: there is no Result<Result<T>>.
Chain a second fallible step with andThen, which takes a function
returning a Result and never nests:
const widgetIdOf = andThen((code: number) =>
code > 0 ? success(`widget-${code}`) : failure(new WidgetError(code)),
)A Success is statically prevented from being an Error, so the
instanceof discrimination cannot be fooled by a success value that
merely happens to be Error-shaped.
v0.2.x, plus a batch of unreleased work on main. maybe and result
offer a matching combinator set — map and andThen, eager orElse and
lazy fallback, and a bridge each way between the two modules
(maybe/fromResult, result/fromMaybe) — and every export carries a
docblock. The match is deliberate rather than mechanical, and so are its
two exceptions: maybe/andThen is a true alias of maybe/map, because a
Maybe cannot nest, whereas result/andThen is genuinely distinct from
result/map, because a Result can; and result/mapError has no maybe
counterpart, because Nothing carries nothing to transform. See
docs/adr/0001-unboxed-maybe-and-result.md
for the rationale behind the encoding and these symmetry choices.
Unreleased on main: an asynchrony layer — /promise, /call and
/abort — built on the same primitives, a /fn grown to hold the whole
function vocabulary, and a rounding-out of the primitives themselves
(result/mapError, maybe/fromNullable, the root entry point). Two
things to know about it:
- Abort propagates upstream. Aborting a promise derived through
thenaborts the one it came from, so cancelling the tail of a chain really cancels the work at the head. The consequence is that two branches off one source can abort each other;detach()severs that link at a branch point. See docs/adr/0002-abort-propagation.md. - The whole library is curryable, decided by arity.
promise/resultify(fail, promise)andpromise/resultify(fail)are both valid, and so areresult/map(fn, value)andresult/map(fn)— every config-takingmaybe/resultcombinator now offers the same dual shape. Which shape a call is in is decided by how many arguments were passed, never by inspecting a value —Nothingisundefined, somap(fn, nothing())applies rather than handing back the Mapper. See docs/adr/0003-currying.md.
Upgrading from 0.1.x or 0.2.x? See the 0.1.0 → 0.3.0 migration guide.
Not yet published to npm; expect breaking changes within 0.x.
MIT — see LICENSE.