Symbol-Keyed Internal State
The mixin examples so far have added a single public property (xxxProperty) — something the mixin is explicitly designed to expose. But most real mixins also need to track internal state: a list of resources they created and are responsible for disposing of. withForm, for example, needs to remember every GeaForm it created via createForm(), so dispose() can tear each one down.
The naive approach is a plain, string-named class field:
const Derived = class extends Base {
// looks harmless...
_managedForms: GeaForm[] = []
createForm(/* ... */) {
const form = new GeaForm(/* ... */)
this._managedForms.push(form)
return form
}
dispose() {
this._managedForms.forEach(form => form.destroy())
if (typeof super.dispose === 'function') super.dispose()
}
}
This works fine in isolation. It stops working fine the moment mixins are stacked, which — per withMixins — is the normal way they're actually used:
class MyComponent extends withMixins(withA11y, withForm, withQuery, Component) {}
A string property name like _managedForms has two problems in this context:
- It can collide. Nothing stops a different mixin author from independently choosing
_managedForms,_managed, or some other name that happens to match. When that happens, one mixin's bookkeeping silently overwrites another's, and resources stop being disposed of — a memory leak that's very hard to trace back to its cause. - It isn't actually private. A string-keyed field shows up in autocomplete,
Object.keys(), and is just as easy for a consumer to read or overwrite by accident as any public property, even though it was never meant to be part of the mixin's public surface.
The Fix: Key Internal State with a Symbol
/** @internal */
export const managedForms = Symbol('managedForms')
export function withForm<TBase extends ComponentConstructor>(Base: TBase) {
const Derived = class extends Base {
/** @internal */
[managedForms]: GeaForm[] = []
createForm(/* ... */) {
const form = new GeaForm(/* ... */)
this[managedForms].push(form)
return form
}
dispose() {
this[managedForms].forEach(form => form.destroy())
if (typeof super.dispose === 'function') super.dispose()
}
}
return Derived as unknown as TBase & MixinConstructor<TBase, WithFormMixin>
}
A Symbol() value is unique by construction — no other mixin can produce the same symbol by picking a similar-looking name, even accidentally. Two mixins can each safely use a property literally named managedForms as long as each holds its own distinct Symbol().
Exporting the Symbol
The symbol itself is exported (with an /** @internal */ JSDoc tag, not stripped from the build) rather than kept fully private to the module. This is a deliberate trade-off: fully hiding it (e.g., as a module-private WeakMap) would make the internal state impossible for the library's own test suite to inspect directly. Exporting it under an explicit @internal marker keeps it inspectable for tests and advanced consumers, while the naming and documentation make clear it isn't part of the mixin's supported public API and may change without notice.
Applying This Retroactively
If you've already published a mixin using a plain string-keyed internal property (as earlier revisions of @geastack-community/a11y and @geastack-community/query did), switching to a Symbol is a breaking change for anyone who was reading or relying on that property directly — which, if it was genuinely treated as internal, should be nobody. Bump accordingly, and note the rename in your changelog so anyone who was poking at internals (intentionally or not) isn't caught off guard.
Set stripInternal under compilerOptions in tsconfig.json to true.