Skip to content

[Refactor]: Replace if-chain modifier dispatch in format.ts with a registry #78

Description

@martyy-code

Current State

In packages/errors/src/error/format.ts:30-47, the formatTemplate function branches on a fixed set of placeholder modifiers (upper, lower, json) through a chain of if statements. The set is internally-defined (the codebase owns it), the modifiers are part of the public message-template contract, and adding a new modifier (e.g. base64, slug, truncate) requires editing the dispatch function.

This is the canonical violation of rule 0009 (Open Extension, Closed Modification):

"A chain of if (kind === A) ... else if (kind === B) ... else if (kind === C) ... puts every case in the same place as the dispatcher. Adding a case means editing the dispatcher. Removing a case means searching the dispatcher for the string. Renaming a case means changing it in the dispatcher and every call site. The function is the centre of gravity for everything related to the enumeration."

The rule's solution: dispatch through a Map<string, Formatter> registry in a separate module; adding a modifier is one row in the table.

Located in:

  • packages/errors/src/error/format.ts:30-47

Problems with current implementation:

  • Adding a modifier means editing the dispatcher function.
  • The set of modifiers is not visible at a glance — a reader has to read every branch to know what exists.
  • The dispatcher mixes the iteration logic with the formatting logic, which violates rule 0007 (top-down composition).
  • The pattern does not age: a fourth modifier is added, a fifth, and the chain becomes the only place to find them.

Proposed State

After refactoring, the modifiers live in a registry module (message-template/modifiers.ts), and formatTemplate reads from the registry:

// message-template/modifiers.ts
type PlaceholderModifier = (value: unknown) => string;
const placeholderModifiers = new Map<string, PlaceholderModifier>([
  ['upper', (value) => String(value).toUpperCase()],
  ['lower', (value) => String(value).toLowerCase()],
  ['json', (value) => JSON.stringify(value)],
]);

// message-template.ts
const formatTemplate = <S extends string>(
  template: S,
  data: Record<ExtractKeys<S>, unknown>
): string => {
  return template.replace(/\{(\w+)(?::(\w+))?\}/g, (fullMatch, fieldName, modifier) => {
    const value = data[fieldName as keyof typeof data];
    if (value === undefined) {
      return fullMatch;
    }
    const formatter = placeholderModifiers.get(modifier ?? '');
    return formatter ? formatter(value) : String(value);
  });
};

Adding a modifier (base64) is one line in modifiers.ts. The dispatcher does not change. The set of modifiers is visible in one file.

Expected improvements:

  • Rule 0009 compliance: the function is closed for modification (the dispatcher does not change) and open for extension (a new row in the registry).
  • The set of modifiers is visible at a glance, in one place.
  • Rule 0007 compliance: the dispatcher reads top-down (one line per concern).
  • A natural extension point for consumers who want to register their own modifiers (a follow-up, not in scope here).

Motivation

This refactoring is needed because:

  • The chain of ifs is the canonical pattern the rule 0009 was written to forbid.
  • The set of modifiers is part of the public message-template contract; consumers who want to add a modifier have no current extension point.
  • The pattern does not age.

Triggers for this work:

  • Technical debt accumulation
  • Maintainability concerns

Risks

Potential risks:

  • Risk 1: The Map.get call has a measurable performance cost compared to the if chain in the hot path. — Mitigation: the formatter template is evaluated at error construction time, not in a tight inner loop; the cost is negligible. If a real performance regression appears, a frozen Record<string, Formatter> is a drop-in replacement with one fewer allocation per call.
  • Risk 2: A consumer depends on the if-chain branch order. — Mitigation: the order was not part of the public contract (only the modifier names were); the registry preserves the same dispatch semantics.
  • Risk 3: Refactoring into a modifiers.ts file changes the package's file layout, which conflicts with P0 chore: merge dev to main - core foundation complete #6 (entity-name file rename). — Mitigation: the two refactors should land together; format.ts becomes message-template.ts and gains a modifiers.ts sibling.

Migration Plan

Migration approach:

  1. Create packages/errors/src/error/message-template/modifiers.ts with the placeholderModifiers registry.
  2. Update formatTemplate to dispatch through the registry.
  3. Combine with P0 chore: merge dev to main - core foundation complete #6: rename format.ts to message-template.ts (or message-template/index.ts if the modifiers file is co-located).
  4. Run the test suite; the public API is unchanged.

Rollback plan: revert the PR.

Backward Compatibility

  • This refactoring maintains full backward compatibility

Scope

Files/Folders affected:

Component(s) Affected

  • Multiple Components

Note: the component_affected dropdown is calibrated for a web template project. The actual affected component is packages/errors.

Priority

  • p0: Critical - Blocking major work or causing bugs
  • p1: High - Important, should do soon
  • p2: Medium - Normal priority
  • p3: Low - Nice to have

Estimated Effort

  • effort: xs - Few minutes
  • effort: s - Half a day

Test Coverage Requirements

  • Existing tests cover this code area (will update)
  • Need to add new tests for this refactor

Testing Approach

Testing strategy:

  • Unit tests: confirm each existing modifier (upper, lower, json) produces the same output as before.
  • New test: confirm a missing modifier falls through to String(value) (the existing default behaviour).

Verification steps:

  1. pnpm --filter @deessejs/errors test:run
  2. pnpm --filter @deessejs/errors type-check
  3. pnpm --filter @deessejs/errors build — public dist/ output matches the previous version's behaviour.

Related Issues / Pull Requests

  • Related audit: « Hors-P0 mais notables » in the internal audit of packages/errors/src/ against rules 0001-0016, August 2026.
  • Related rule: docs/engineering/architecture/rules/0009-open-extension-closed-modification.md.
  • Related rule: docs/engineering/architecture/rules/0007-top-down-composition.md.
  • Related: P0 chore: merge dev to main - core foundation complete #6 (rename format.ts to message-template.ts) — should land together.

Relevant Documentation

  • Architecture doc: docs/engineering/architecture/rules/0009-open-extension-closed-modification.md
  • Architecture doc: docs/engineering/architecture/rules/0007-top-down-composition.md

Pre-Submission Checklist

  • I have searched existing issues for related refactoring requests
  • Risks and migration plan are documented
  • Test coverage approach is defined
  • I understand this issue will be labeled according to the project taxonomy
  • This is NOT a security vulnerability (see security note above)

Metadata

Metadata

Assignees

No one assigned

    Labels

    p1: highRequired for next releasetype: refactorRefactoring / code restructuring

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions