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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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