Repository navigation
Description
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.getcall has a measurable performance cost compared to theifchain 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 frozenRecord<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.tsfile 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.tsbecomesmessage-template.tsand gains amodifiers.tssibling.
Migration Plan
Migration approach:
- Create
packages/errors/src/error/message-template/modifiers.tswith theplaceholderModifiersregistry. - Update
formatTemplateto dispatch through the registry. - Combine with P0 chore: merge dev to main - core foundation complete #6: rename
format.tstomessage-template.ts(ormessage-template/index.tsif the modifiers file is co-located). - 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:
packages/errors/src/error/format.ts(refactored; may be renamed per P0 chore: merge dev to main - core foundation complete #6)packages/errors/src/error/message-template/modifiers.ts(new file)packages/errors/tests/(if any test exercises a specific modifier)
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:
pnpm --filter @deessejs/errors test:runpnpm --filter @deessejs/errors type-checkpnpm --filter @deessejs/errors build— publicdist/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.tstomessage-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