You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The lead's review of PR #87 (branded ErrorInstance) noted that the implementation is structurally OOP (carries methods, owns mutable state, has identity) but written in a functional style that requires casts and symbols to simulate class semantics. Three options were proposed:
Phantom brand: 0 octet runtime, type-only. Loses the runtime guard capability that FACTORY_SYMBOL already provides.
Branding runtime via object literal: builds the object in one pass with the brand as a literal key. Cleaner than the current new Error() + cast + helper pattern. Pure refactor; preserves the FP API.
Class-based internals, FP externals: a private class implements the methods and the brand; the public API is the factory function that returns a typed object (not the class).
Option 2 lands cleanly inside PR #87. Option 3 is a structural shift that deserves its own decision.
Decision to evaluate
The lead suggested moving the implementation to OOP while keeping the API purely functional. Concretely:
A private class (ErrorInstanceImpl<TFields>) owns the methods (from, addNote), the mutable state (notes, causes, context), and the brand assignment.
The factory error() instantiates the class internally, binds the public-facing methods, and returns a typed object whose type is exposed but whose class is not.
The public API (error, is, causes, raise) is unchanged: factory functions return typed values, no new is exposed.
This would resolve several smells from the current shape:
The brand-instance helper (brandInstance(instance)) becomes the class constructor's job. The cast on the brand property disappears.
The addNote / from methods, currently attached via instance.addNote = ... with no type narrowing, are real class methods with proper this types.
The brand is set in the constructor (the only assignment site), making it impossible to construct an ErrorInstance that lacks the brand.
Rule 0014's "no export class" remains satisfied: the class is internal to the module; only the factory function is exported.
Trade-offs
Easier:
Removes the as cast at the construction site.
Methods get this types instead of capturing instance via const.
The brand becomes constructor-enforced, not a property assignment after the fact.
Aligns with how mature error libraries (VError, http-errors, Node's own ERR_* patterns) implement this internally.
Harder:
The bundle shape changes: the class survives in the compiled output, but only the factory call sites see it. Tree-shaking removes the class symbol after the factory call returns (the returned value is an object, not the class). Bundle size impact: minimal.
Rule 0014 is satisfied by keeping the class non-exported. This requires discipline — any temptation to export class ErrorInstanceImpl for "convenience" would violate the rule. The naming convention (Impl suffix, never exported) is the guard.
The brand property becomes constructor-enforced. If a future contributor wants to bypass the constructor (e.g. for testing), they have to either reflect the brand manually or use a testing helper. The current brandInstance helper from PR feat(errors): brand ErrorInstance so the type can be trusted at boundaries #87 is the testing escape hatch and should be preserved as a private export.
Stack interaction
PR #87 is the bottom of a 3-PR sequence described in its description:
(later) — shrink public function parameters, retire dead guards.
The class-based refactor would sit at position 0 of the stack, before#87. It would obsolete the brandInstance helper (the constructor sets the brand) and the as cast (the class is the type). The brand-as-symbol concept survives — the class declares [ErrorInstanceBrand]: 'ErrorInstance' on its instances — but the construction shape becomes natural.
The cleanest sequence is therefore:
Refactor error() to a class internally, ship as a separate PR (this issue).
packages/errors/src/error/error.ts — replace the inner functional implementation with a private class. The public error() factory signature is unchanged.
packages/errors/src/error/types.ts — ErrorInstance<T> becomes InstanceType<typeof ErrorInstanceImpl> (the public type is derived from the class).
Context
The lead's review of PR #87 (branded
ErrorInstance) noted that the implementation is structurally OOP (carries methods, owns mutable state, has identity) but written in a functional style that requires casts and symbols to simulate class semantics. Three options were proposed:FACTORY_SYMBOLalready provides.new Error()+ cast + helper pattern. Pure refactor; preserves the FP API.Option 2 lands cleanly inside PR #87. Option 3 is a structural shift that deserves its own decision.
Decision to evaluate
The lead suggested moving the implementation to OOP while keeping the API purely functional. Concretely:
ErrorInstanceImpl<TFields>) owns the methods (from,addNote), the mutable state (notes,causes,context), and the brand assignment.error()instantiates the class internally, binds the public-facing methods, and returns a typed object whose type is exposed but whose class is not.error,is,causes,raise) is unchanged: factory functions return typed values, nonewis exposed.This would resolve several smells from the current shape:
brandInstance(instance)) becomes the class constructor's job. The cast on the brand property disappears.addNote/frommethods, currently attached viainstance.addNote = ...with no type narrowing, are real class methods with properthistypes.ErrorInstancethat lacks the brand.export class" remains satisfied: the class is internal to the module; only the factory function is exported.Trade-offs
Easier:
ascast at the construction site.thistypes instead of capturinginstanceviaconst.Harder:
export class ErrorInstanceImplfor "convenience" would violate the rule. The naming convention (Impl suffix, never exported) is the guard.brandInstancehelper from PR feat(errors): brand ErrorInstance so the type can be trusted at boundaries #87 is the testing escape hatch and should be preserved as a private export.Stack interaction
PR #87 is the bottom of a 3-PR sequence described in its description:
ErrorInstance<T>. Pure addition.is()predicate (issue [BUG] is() should return TypeScript type predicate for narrowing #35). Brand becomes reachable to consumers.The class-based refactor would sit at position 0 of the stack, before #87. It would obsolete the
brandInstancehelper (the constructor sets the brand) and theascast (the class is the type). The brand-as-symbol concept survives — the class declares[ErrorInstanceBrand]: 'ErrorInstance'on its instances — but the construction shape becomes natural.The cleanest sequence is therefore:
error()to a class internally, ship as a separate PR (this issue).brandInstancehelper dies.Scope
packages/errors/src/error/error.ts— replace the inner functional implementation with a private class. The publicerror()factory signature is unchanged.packages/errors/src/error/types.ts—ErrorInstance<T>becomesInstanceType<typeof ErrorInstanceImpl>(the public type is derived from the class).packages/errors/src/is/index.ts—is()can drop its structural checks if the brand is a class-checkable nominal marker. (This is the cleanup PR [Refactor]: Remove redundant typeof/null guard after narrowing in is/index.ts #73 / [Refactor]: Remove silent try/catch around instanceof in is/index.ts #72.)Revisit conditions
This issue should be revisited when:
Symbol.hasInstancecustomisation).Effort and priority
m - 1-2 days. The class is straightforward; the tests pass unchanged.p2: medium. The class refactor is an improvement but not blocking. PR feat(errors): brand ErrorInstance so the type can be trusted at boundaries #87 lands the brand without it; the class work can follow in a focused PR.arch-rules-auditif bundled with the brand work; otherwise a new milestone for the type-system revamp.Related
ErrorInstance. The class refactor obsoletes parts of [Refactor]: Brand ErrorInstance so the type can be trusted at function boundaries #86 (the helper, the cast) but keeps the brand declaration.is()predicate. Lands naturally after the class because the brand check becomes a class check.Pre-Submission Checklist