Background
How do you architect your code to allow circular function invocations across modules, where you basically have several mixins breaking down a class into several parts?
I have this essentially in the main index.ts file:
import Base from './base'
import treeMixin from './tree'
import forkMixin from './fork'
import nestMixin from './nest'
import knitMixin from './knit'
import textMixin from './text'
import cardDeckMixin from './card/deck'
import cardCodeMixin from './card/code'
Object.assign(Base.prototype, treeMixin)
Object.assign(Base.prototype, forkMixin)
Object.assign(Base.prototype, nestMixin)
Object.assign(Base.prototype, knitMixin)
Object.assign(Base.prototype, textMixin)
Object.assign(Base.prototype, cardCodeMixin)
Object.assign(Base.prototype, cardDeckMixin)
The Base
looks like this essentially:
export default class Base {}
The functionality of Base
is defined in these separate "mixins", where I have things like this:
nest.ts
export default {
mintNestTree(nest, seed) {
if (this.isTextNest(nest)) {
return this.getTextNest(nest, seed)
} else if (shared.isMark(nest)) {
} else if (shared.isSimpleTerm(nest)) {
}
},
// ...
}
text.ts
export default {
isTextNest(nest) {
if (nest.line.length > 1) {
return false;
}
if (nest.line.length === 0) {
return false;
}
let line = nest.line[0];
if (line.like === "text") {
return true;
}
return false;
},
// ...
};
More...
And it gets a lot more complex. Essentially I am building a complicated compiler, and parsing an AST. There is lots of recursion, yet I want to break these into separate files so it's not just one large 5k line file. Breaking it apart by theme essentially.
I use it like:
const base = new Base
base.isTextNest(node) // ...
Problem
I am getting this error in nest.ts
at isTextNest
:
Unsafe member access .isTextNest on an
any
value.this
is typed asany
.
You can try to fix this by turning on thenoImplicitThis
compiler option, or adding athis
parameter to the function. eslint@typescript-eslint/no-unsafe-member-access
.
How can I reorganize my code to make it suitable for TypeScript? (I am in the process of migrating a decently-sized JS project to TypeScript). Can I somehow add typing annotations to this
, or do I need to maybe stop using Object.assign(Base.prototype
and instead maybe do:
Object.assign(Base.prototype, Object.keys(mixin).reduce((m, x) => {
m[x] = (...params) => mixin[x].apply(null, [this].concat(params))
}, {})
Can it be done like that somehow, or in any less hacky way? If not, what is a standard way to reorganize my code?
I can do this:
mintNestTree(this: Base, nest: ParserNestNodeType, seed) {
if (this.isTextNest(nest)) {
return this.getTextNest(nest, seed)
} else if (shared.isMark(nest)) {
} else if (shared.isSimpleTerm(nest)) {
}
},
Example
Here is a working simple example demonstrating my point more concisely.
class Base {}
const mixinA = {
fnA(this: Base) {
return this.fnB(1) this.fnC(2)
}
}
const mixinB = {
fnB(x: number): number { return x * x }
}
const mixinC = {
fnC(this: Base, x: number): number { return this.fnB(x) / x }
}
Object.assign(Base.prototype, mixinA)
Object.assign(Base.prototype, mixinB)
Object.assign(Base.prototype, mixinC)
CodePudding user response:
Can I somehow add typing annotations to
this
Yes, if it is not automatically inferred by TypeScript, you can "simulate" a this
argument to specify its expected type:
The JavaScript specification states that you cannot have a parameter called
this
, and so TypeScript uses that syntax space to let you declare the type forthis
in the function body.
function (this: User) {
return this.admin;
}
In your case, you have several separate mixins, but some of them depending on others (like nest
using method from text
, or fnC
using fnB
).
Obviously, typing this
as the initial empty Base
would not make TS happy, as it would not see the dependency methods (isTextNest
or fnB
).
do I need to maybe stop using
Object.assign(Base.prototype
it's not catching that
Object.assign
has mixed in other mixins as well.
It would have been great indeed that TypeScript sees the "methods augmentation" (mixins) done through Object.assign
, but unfortunately it does not follow the runtime operation for static analysis.
As you already guessed, unfortunately the implementation of mixins with TypeScript requires more than the simple Object.assign
pattern.
But worry not, mixins are obviously still a quite common pattern, so it is definitely do-able in TypeScript, and there is even official documentation for that:
The pattern relies on using generics with class inheritance to extend a base class. TypeScript’s best mixin support is done via the class expression pattern.
So, leaving aside the circular calls for now, we have a class building pattern that explicitly defines the mixins dependencies. In your example case, it would look like:
type GConstructor<T = {}> = new (...args: any[]) => T;
class Base { }
function mixinBcomputeSquare<TBase extends GConstructor>(MyBase: TBase) {
return class MixinBcomputeSquare extends MyBase {
fnB(x: number): number { return x * x }
}
}
function mixinCcomputeDivision<
TcomputeSquare extends GConstructor<{ fnB(x: number): number }>
>(MyBase: TcomputeSquare) {
return class MixinCcomputeDivision extends MyBase {
fnC(x: number): number {
// Safe call to this.fnB!
return this.fnB(x) / x
}
}
}
function mixinAcombineBandC<
TBandC extends GConstructor<{
fnB(x: number): number;
fnC(x: number): number
}>
>(MyBase: TBandC) {
return class MixinAcombineBandC extends MyBase {
fnA() {
// Safe calls to this.fnB and this.fnC!
return this.fnB(1) this.fnC(2)
}
}
}
// Successively apply the desired mixins,
// respecting their dependency order
const ComposedClass = mixinAcombineBandC(
mixinCcomputeDivision(
mixinBcomputeSquare(Base)
)
)
// Usage
const instance = new ComposedClass() // Okay
instance.fnC(3) // Okay
instance.fnA() // Okay
As for circular calls, unfortunately we hit a limitation there.
As we can obviously see from the above example, the currently recommended pattern to implement mixins in TypeScript, requires a one-way build direction of dependencies (the call order of functions that apply each mixin).
The easiest workaround I see is to break the circular dependency by NOT explicitly specifying the class dependency in the GConstructor
generic type argument above, but instead to override the inferred this
within the method definition, since we know that at runtime, the mentioned method will be there:
// fnB0 depends on fnB
function mixinB0<TB extends GConstructor<{ fnB(x: number): number }>>(MyBase: TB) {
return class MixinB0 extends MyBase {
fnB0(x: number): number {
console.log("called this.fnB0")
if (x === 0) return this.fnB(x) // Safe call to this.fnB
else return x
}
}
}
// Break the circular dependency by NOT explicitly specifying the dependency...
function mixinBcomputeSquare2<TBase extends GConstructor>(MyBase: TBase) {
return class MixinBcomputeSquare extends MyBase {
// ...but override the inferred `this` where necessary
// since we know that in runtime `this` will have more methods
fnB(this: TBase & { fnB0(x: number): number }, x: number): number {
// fnB depends on fnB0
if (x % 2 === 1) return this.fnB0(x) // Okay
else return x * x
}
}
}
// ...other mixins
// Successively apply the desired mixins,
// respecting their dependency order
const ComposedClass = mixinAcombineBandC(
mixinCcomputeDivision(
mixinB0(
mixinBcomputeSquare2(Base) // Actually also depends on mixinB0
)
)
)
// Usage
const instance = new ComposedClass() // Okay
instance.fnA() // Okay
// Result:
// [LOG]: "called this.fnB0"