简体   繁体   中英

Unsafe member access <method> on an `any` value. `this` is typed as `any` in TypeScript for compiler project. How to architect mixins for TypeScript?

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 as any .
You can try to fix this by turning on the noImplicitThis compiler option, or adding a this 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)) {
  }
},

And use the this parameter , but it is still erroring because it seems like it's not catching that Object.assign has mixed in other mixins as well.

在此处输入图像描述

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)

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 for this 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

Playground Link


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" 

Playground Link

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM