Skip to content

refactor!(error): replace createTaggedError with defineErrors — Rust-style namespaced errors#101

Merged
braden-w merged 10 commits intomainfrom
braden-w/specs-article
Mar 3, 2026
Merged

refactor!(error): replace createTaggedError with defineErrors — Rust-style namespaced errors#101
braden-w merged 10 commits intomainfrom
braden-w/specs-article

Conversation

@braden-w
Copy link
Collaborator

@braden-w braden-w commented Mar 3, 2026

Replaces the createTaggedError builder chain with defineErrors — a single function that takes a config object of constructor functions and returns namespaced error factories that produce Err<...> directly.

The old builder API (createTaggedError('FooError').withFields(...).withMessage(...)) had three problems: phantom-type state tracking made the type machinery complex, the dual-mode .withMessage() design created a branching API surface nobody used, and call sites always needed an extra Err() wrap for trySync/tryAsync. Rust's thiserror crate showed a better pattern — short variant names under a namespace, one constructor shape per variant, done.

// BEFORE: builder chain with phantom types
const NetworkError = createTaggedError("NetworkError")
  .withFields<{ cause: string }>()
  .withMessage(({ cause }) => `Connection failed: ${cause}`);

const result = trySync({
  try: () => fetch(url),
  catch: (e) => Err(NetworkError({ cause: extractErrorMessage(e) })),
  //           ^^^^ manual Err() wrap
});

type NetworkError = TaggedError<"NetworkError", typeof NetworkError>;
//                  ^^^^^^^^^^^ phantom generic gymnastics
// AFTER: plain constructor functions, Err-by-default
const HttpError = defineErrors({
  Connection: ({ cause }: { cause: string }) => ({
    message: `Connection failed: ${cause}`,
    cause,
  }),
  Parse: ({ cause }: { cause: string }) => ({
    message: `Failed to parse: ${cause}`,
    cause,
  }),
});

const result = trySync({
  try: () => fetch(url),
  catch: (e) => HttpError.Connection({ cause: extractErrorMessage(e) }),
  //            ^^^^^^^^^ returns Err<...> directly
});

type HttpError = InferErrors<typeof HttpError>;
//               ^^^^^^^^^^^ union of all variants

The namespace provides context (HttpError.Connection), so variant names stay short. Value and type share the same name — just like class declarations.

What changed

  • createTaggedError, TaggedError type, and the builder in utils.ts are removed
  • defineErrors(config) is the single entry point — ~12 lines of runtime
  • Every factory returns Err<Readonly<{ name: K } & ReturnType<Fn>>> directly
  • InferError<typeof Ns.Variant> extracts a single error type; InferErrors<typeof Ns> extracts the union
  • ValidatedConfig catches accidental name keys at the type level with a descriptive error message
  • extractErrorMessage moved to its own file (behavior unchanged)

Naming convention

The API enforces a two-level naming scheme:

Namespace name = domain + Error suffix (e.g., HttpError, RecorderError, DbError)
Variant name = specific failure mode, never generic (e.g., Connection, AlreadyRecording, QueryFailed)

Anti-patterns for variant names:

  • Service — too generic. RecorderError.Service({ message: '...' }) forces the caller to supply meaning that the variant name should carry. Use RecorderError.AlreadyRecording() instead.
  • Error — redundant with the namespace. DbError.Error says nothing.
  • Bare Failed — too vague on its own. Prefix with what failed: ConnectionFailed, InitFailed.

Why short variant names?

┌──────────────────────────────────────────────────────┐
│  v1 keys              │  v2 keys (Rust-style)        │
├───────────────────────┼──────────────────────────────┤
│  ConnectionError      │  Connection                  │
│  ParseError           │  Parse                       │
│  ResponseError        │  Response                    │
├───────────────────────┼──────────────────────────────┤
│  HttpError.Connection │  HttpError.Connection        │
│  Error               │                              │
│  (redundant suffix)   │  (namespace provides context)│
└──────────────────────────────────────────────────────┘

HttpError.ConnectionError stutters. HttpError.Connection reads naturally. The name field stamps "Connection" — discrimination works on the variant, not the fully-qualified name.

Why Err-by-default?

trySync/tryAsync catch handlers are the 90% use case, and they expect Err<E>. The old API required Err(MyError({...})) at every call site. Now factories return Err<...> directly. For the rare case you need a plain error object, .error unwraps it.

Different shapes, one abstraction

No modes, no builder state. Just different function signatures:

const RecorderError = defineErrors({
  AlreadyRecording: () => ({ message: "A recording is already in progress" }),
  InitFailed:       ({ cause }: { cause: unknown }) => ({ message: `Failed to init: ${extractErrorMessage(cause)}`, cause }),
  StreamAcquisition: ({ cause }: { cause: unknown }) => ({ message: `Stream failed: ${extractErrorMessage(cause)}`, cause }),
});

braden-w added 10 commits March 2, 2026 15:56
Add spec for replacing createTaggedError with defineErrors — a
constructor-function-based API that eliminates the builder chain,
phantom types, and mode complexity. Mark 4 prior tagged-error specs
as superseded with forward references.
Create src/error/defineErrors.ts with ~12-line runtime that stamps `name`
from keys and generates both plain and Err-wrapped factories. Add new types
(ErrorBody, ErrorsConfig, FactoryPair, DefineErrorsReturn, InferError,
InferErrorUnion) to types.ts, replacing the old TaggedError generic.
36 tests covering all function shapes: zero-arg static, call-site message,
computed message, fields+message, mixed shapes, InferError/InferErrorUnion
type extraction, JSON serialization, edge cases. Fix ErrorBody type to
not use JsonObject intersection (breaks optional fields). Fix runtime to
put name last in spread to prevent override.
BREAKING CHANGE: `createTaggedError` and `TaggedError` type are removed.
Use `defineErrors` with `InferError`/`InferErrorUnion` instead.

Move `extractErrorMessage` to its own file. Delete the old builder
(utils.ts) and its test file (createTaggedError.test.ts).
Update 28 documentation files to use the new defineErrors API.
Replace all createTaggedError builder chain examples with defineErrors
constructor function pattern. Replace TaggedError<T, F> generics with
InferError<typeof errors, 'Name'> or inline Readonly<...> types.
Update imports, explanations, and API reference sections.
Rename 'constructor' to 'ctor' to avoid shadowing global. Replace
'Function' cast with explicit function type. Add biome-ignore comments
for necessary 'any' usage in type utility types.
…-default

BREAKING CHANGE: defineErrors API redesigned to use short variant names
under a namespace (like Rust's thiserror) instead of keys ending in Error.

- Factories now return Err<...> directly (no dual FooError/FooErr factories)
- Keys are short variant names (Connection, Parse) not ConnectionError
- InferError<T> takes a single factory (typeof HttpError.Connection)
- InferErrors<T> replaces InferErrorUnion<T> for union extraction
- ValidatedConfig provides descriptive error when 'name' key is used
- All tests rewritten for v2 API
@braden-w braden-w merged commit 2660f3b into main Mar 3, 2026
2 checks passed
@braden-w braden-w deleted the braden-w/specs-article branch March 3, 2026 03:25
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