feat!: redesign TaggedError — flat fields, sealed .withMessage()#99
Open
feat!: redesign TaggedError — flat fields, sealed .withMessage()#99
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Redesigns
createTaggedErrorfrom first principles based on analysis of 321 error call sites across the Epicenter codebase.context/cause—error.statusinstead oferror.context.status..withContext()/.withCause()replaced by.withFields<T>().withMessage()— two mutually exclusive modes: without it,messageis required at the call site; with it, the template owns the message entirely (messagenot in the factory input type).withMessage()is optional, not a required terminal step. The builder returns usable factories at every stageNoReservedKeyssimplified — onlynameis reserved;messageis either a built-in input or absent depending on.withMessage()usageBefore
After
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
bun test src/error/createTaggedError.test.ts)arktypedep)bun run build).withMessage()