Skip to content

feat!: redesign TaggedError — flat fields, sealed .withMessage()#99

Open
braden-w wants to merge 14 commits intomainfrom
braden-w/granular-error-types
Open

feat!: redesign TaggedError — flat fields, sealed .withMessage()#99
braden-w wants to merge 14 commits intomainfrom
braden-w/granular-error-types

Conversation

@braden-w
Copy link
Collaborator

@braden-w braden-w commented Feb 26, 2026

Summary

Redesigns createTaggedError from first principles based on analysis of 321 error call sites across the Epicenter codebase.

  • Flat fields replace nested context/causeerror.status instead of error.context.status. .withContext()/.withCause() replaced by .withFields<T>()
  • Sealed .withMessage() — two mutually exclusive modes: without it, message is required at the call site; with it, the template owns the message entirely (message not in the factory input type)
  • Factories available immediately.withMessage() is optional, not a required terminal step. The builder returns usable factories at every stage
  • NoReservedKeys simplified — only name is reserved; message is either a built-in input or absent depending on .withMessage() usage

Before

const { ResponseError } = createTaggedError('ResponseError')
  .withContext<{ status: number }>()
  .withMessage(({ context }) => `HTTP ${context.status}`);
ResponseError({ context: { status: 404 } });             // template message
ResponseError({ context: { status: 404 }, message: 'x' }); // override

After

// With sealed .withMessage() — template owns message
const { ResponseError } = createTaggedError('ResponseError')
  .withFields<{ status: number }>()
  .withMessage(({ status }) => `HTTP ${status}`);
ResponseError({ status: 404 });  // message: "HTTP 404"

// Without .withMessage() — message required at call site
const { SimpleError } = createTaggedError('SimpleError');
SimpleError({ message: 'Something went wrong' });

Why seal the message?

Analysis showed no error type uses .withMessage() as a default that some call sites use and others override — it's always all-or-nothing. The override was an escape hatch masking two design problems: (a) the template needs better fields, or (b) the error should be a different type. 59% of call sites use sealed .withMessage(), 41% skip it and require call-site message.

Test plan

  • 44 unit tests pass (bun test src/error/createTaggedError.test.ts)
  • Full test suite passes (57 pass, 1 pre-existing failure from missing arktype dep)
  • Build succeeds (bun run build)
  • Specs, docs, README, CHANGELOG, ERROR_HANDLING_GUIDE all updated
  • Verify no downstream consumers rely on message override with .withMessage()

…optional

Remove the `message?: string` escape hatch from ErrorCallInput so the
template function always owns the message. Make the factory input
parameter optional when it resolves to Record<never, never> (no context,
no cause), enabling `FooErr()` instead of `FooErr({})`.

BREAKING CHANGE: ErrorCallInput no longer accepts a `message` field.
All call sites that pass `{ message: "..." }` must migrate to structured
context with `.withMessage()` templates.
Remove message override examples, convert raw Err object literals to
createTaggedError factories, update FooErr({}) to FooErr() for empty
inputs, and mark superseded spec as outdated.
First-principles redesign of TaggedError: flat fields over nested
context, removal of first-class cause, message computed solely by
templates. All decisions finalized.
- Use Record<never, never> consistently as TFields default (not {})
- Document that factory input must be required when TFields has
  required keys (fixes bug in implementation sketch)
- Add serialization/deserialization as an explicit win for flat design
- Note that ValidFields error diagnostics should be tested during impl
Replace the nested `TaggedError<TName, TContext, TCause>` type with a
flat `TaggedError<TName, TFields>` that spreads additional fields
directly on the error object. Remove WithContext and WithCause helper
types. Add ValidFields<T> compile-time guard to reject reserved keys
(name, message).

BREAKING CHANGE: TaggedError now takes 2 type params instead of 3.
Fields are spread flat instead of nested under `context`/`cause`.
Replace .withContext()/.withCause() with single .withFields<T>() method.
Fields are spread flat on the error object instead of nesting under
context/cause. Message function receives TFields directly. Factory input
is just TFields (optional when empty or all-optional). Reserved keys
(name, message) prevented via NoReservedKeys constraint.

BREAKING CHANGE: .withContext() and .withCause() removed. Use
.withFields<T>() instead. Factory call sites pass fields directly
instead of { context: {...} }.
Complete test rewrite covering all three error tiers (static, reason-only,
structured), Err-wrapped factories, optional input handling, message
auto-computation, ReturnType extraction, JSON serialization, type safety,
builder shape enforcement, and edge cases. 41 tests, all passing.
Rewrite src/error/README.md to document the new flat API with three
tiers of error complexity (static, reason-only, structured). Remove all
references to .withContext()/.withCause() and nested context. Mark spec
as implemented with all checklist items complete. Fix lint warnings in
test file.
Update the implementation sketch and namespace collision sections to
reflect the actual pattern used: `{ name?: never; message?: never }`
intersection constraint instead of the self-referential
`ValidFields<P>` constraint which causes circular reference errors
in TypeScript. Both approaches are now documented with trade-offs.
Remove ValidFields<T> from public API (unused by consumers). Update
spec to document the next phase: dropping .withMessage() entirely and
making message a required call-site input. The spec now captures the
full reasoning — why templates were dead code in practice, why the
reason convention is a code smell, and why the builder's value is
typed name + fields, not message generation. All docs updated for
the flat .withFields() API.
Analysis of 321 error call sites across Epicenter revealed 59% have
static/predictable messages (benefit from defaults) while 41% need
dynamic call-site messages. This killed both extremes: "always derive
from template" and "never use templates." The final design: message
is a call-site input by default, .withMessage() optionally provides
a default that call sites can override. The spec now documents all
three iterations of the message debate with the data that drove each
decision.
…withMessage() default

Builder now returns usable factories immediately without requiring .withMessage()
as a terminal step. Without .withMessage(), message is required at the call site.
With .withMessage(), message is optional and the template provides a default that
call sites can always override.

- NoReservedKeys reduced to { name?: never } — message is a built-in input
- Added IsInputOptional<TFields> for input optionality when all fields are optional
- Typed ErrorBuilder and DefaultedFactories for both API shapes
- 46 tests covering all builder combinations and edge cases
- Spec updated to mark Phase 2 as complete
.withMessage() now seals the message entirely. Two mutually exclusive
modes: without it, message is required at the call site; with it,
message is NOT in the factory input type.

Analysis of 321 call sites showed no error type uses .withMessage() as
a default that some call sites override. The override was an escape
hatch masking design problems. Removing it forces better error design
and simplifies the type system.

Implementation: remove messageOverride logic, rename DefaultedFactories
to SealedFactories, update factory input types. Tests: 44 pass.
@braden-w braden-w changed the title feat!: remove message override, make empty input optional feat!: redesign TaggedError — flat fields, sealed .withMessage() Feb 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant