The Mixin Pattern In TypeScript – All You Need To Know Part 2

typescript

typescript
In my previous post, I introduced you to the mixin pattern in TypeScript. The whole pattern can actually be summarised in just three lines:

export type AnyFunction<A = any> = (...input: any[]) => A
export type AnyConstructor<A = object> = new (...input: any[]) => A

export type Mixin<T extends AnyFunction> = InstanceType<ReturnType<T>>

The expressiveness of the mixin pattern is truly amazing and required only a few lines of code. All of them – type definitions. Zero-cost abstraction.

Unfortunately, the definitions from above come with certain drawbacks. The most important of them is the problem of recursive references. To brush up, recursive references are valid in class definitions:

class Class1 {
    another     : Class2
}

class Class2 {
    another     : Class1
}

But corresponding mixin definition generates a compilation error:

export const SampleMixin1 = <T extends AnyConstructor<object>>(base : T) =>
class SampleMixin1 extends base {
    another             : SampleMixin2
}
export type SampleMixin1 = Mixin<typeof SampleMixin1>


export const SampleMixin2 = <T extends AnyConstructor<object>>(base : T) =>
class SampleMixin2 extends base {
    another             : SampleMixin1
}
export type SampleMixin2 = Mixin<typeof SampleMixin2>

A workaround exists (using interface declaration for mixin type), but when using it, the compiler seems to go crazy and generate a lot of false positives in incremental compilation mode. This mode is used by IDEs for error highlighting. Overall, it turned out to be a major inconvenience.

Another inconvenience was the mixin ordering in a class creation. Consuming multiple mixins require you to deeply scan all mixin dependencies and place them in correct order and finally apply them to the base class. An example from our code base with class names changed:

export const BuildSomeClass = (base : typeof BaseClass = BaseClass) =>
    Mixin1(
    Mixin2(      // all of these mixin
    Mixin3(      // were hand-picked
    Mixin4(      // and placed in the correct order
    Mixin5(
    Mixin6(
    Mixin7(
    Mixin8(
    Mixin9(
    Mixin10(
    Mixin11(
    Mixin12(
    Mixin13(
    Mixin14(
        base
    ))))))))))))))

export class SomeClass extends BuildSomeClass(BaseClass) {}

So the minimal pattern from the previous post is something like a manual gearbox – it gives you full control but requires a lot of attention. For some areas it can be a perfect fit, but we found that we want more coding convenience and continued the research. This led to the evolution of the mixin pattern.

Representing mixin as a class

The recursive references problem appeared because the mixin was represented as a return value of a function. The only way to fix it, is to represent the mixin as a class. What class to choose for mixin representation? Of course the “minimal” class – the class constructed from the minimal necessary number of mixin functions applications.

const SampleMixin1Lambda = <T extends AnyConstructor<SomeOtherMixin>>(base : T) =>

class SampleMixin1 extends base {
}

class SampleMixin1 extends SampleMixin1Lambda(SomeOtherMixinLambda(Object)) {}

In the example above, SampleMixin1 requires only SomeOtherMixin, so its “minimal” class can be constructed by applying the lambdas of those mixins to some base class.

An interesting outcome from this representation choice is that we can instantiate the mixin class directly – after all it is a regular TypeScript class. But we still need to manually list the requirements.

Now, the mixin is represented with a class, and recursive references problem seemingly goes away:

const SampleMixin1Lambda = <T extends AnyConstructor<object>>(base : T) =>
class SampleMixin1 extends base {
    another : SampleMixin2
}
class SampleMixin1 extends SampleMixin1Lambda(Object) {}


const SampleMixin2Lambda = <T extends AnyConstructor<object>>(base : T) =>
class SampleMixin2 extends base {
    another : SampleMixin1
}
class SampleMixin2 extends SampleMixin2Lambda(Object) {}

But, it actually does not, if the 2nd mixin requires the 1st:

const SampleMixin1Lambda = <T extends AnyConstructor<object>>(base : T) =>
class SampleMixin1 extends base {
    another             : SampleMixin2
}
class SampleMixin1 extends SampleMixin1Lambda(Object) {}


const SampleMixin2Lambda = <T extends AnyConstructor<SampleMixin1>>(base : T) =>
class SampleMixin2 extends base {
    another             : SampleMixin1
}
// TS2506: 'SampleMixin2' is referenced directly or indirectly in its own base expression.
class SampleMixin2 extends SampleMixin2Lambda(SampleMixin1) {}

So this notation solves the problem only partially and needs to be improved. We won’t go into details of the implementation process and instead present the final results in the next section.

The mixin class

We suggest a new pattern that solves both the recursive references and manual requirements problems. The implementation now contains code, not just types. The results are currently published as part of the Chronograph npm  package and all definitions can be found in the src/class/BetterMixin.ts

We did not publish the new pattern as a standalone library, as it is still evolving. We encourage experiments with it and hope to receive more feedback from the community so we can refine it further. See the “Further work” section below.

The core part of the pattern is now a function Mixin which constructs a “minimal” class. As arguments, it receives an array of requirements and a mixin lambda. For a mixin without any requirements it will look like this:

class Mixin1 extends Mixin(
    [],
    (base : AnyConstructor) =>

    class Mixin1 extends base {
        prop1        : string
    }
){}

Now let’s define what is a “requirement”. Requirements can be of two types:

  • Another mixin class, which needs to be already consumed by the base class. The requirements of this type can appear several times in the requirements array, in any order.
  • A specific base class, from which this mixin can be derived. Mixin can also be derived from the subclass of this base class. It is optional and can appear only as the last argument of the requirements array.

The requirements of the mixin needs to be listed three times:

  • as an array of constructor functions, in the 1st argument of the Mixin function
  • as an instance type intersection, in the 1st type argument for the AnyConstructor type
  • as a static type intersection, in the 2nd type argument for the AnyConstructor type

For example, Mixin2 requires Mixin1:

class Mixin2 extends Mixin(
    [ Mixin1 ],
    (base : AnyConstructor<Mixin1, typeof Mixin1>) =>

    class Mixin2 extends base {
    }
){}

And Mixin3 requires both Mixin1 and Mixin2 (even though it is redundant, since Mixin2 already requires Mixin1, but suppose we don’t know the implementation details of the Mixin2):

class Mixin3 extends Mixin(
    [ Mixin1, Mixin2 ],
    (base : AnyConstructor<Mixin1 & Mixin2, typeof Mixin1 & typeof Mixin2>) =>

    class Mixin3 extends base {
    }
){}

Now, Mixin4 requires Mixin3, plus, it requires the base class to be SomeBaseClass:

class SomeBaseClass {}

class Mixin4 extends Mixin(
    [ Mixin3, SomeBaseClass ],
    (base : AnyConstructor<
        Mixin3 & SomeBaseClass, typeof Mixin3 & typeof SomeBaseClass
    >) =>

    class Mixin4 extends base {
    }
){}

The requirements are “scanned” deep and included only once. Also all minimal classes are cached – for example the creation of the Mixin3 will reuse the minimal class of the Mixin2 instead of creating a new intermediate class. This means that all edges of the mixin dependencies graph are created only once (up to the base class).

Requirements may not form cycles – which will generate both compilation error and run-time stack overflow.

The typing for the Mixin function will provide a compilation error, if requirements do not match, e.g. some requirement is listed in the array but missed in the types. This protects you from trivial mistakes. However, the typing is done up to 5 requirements only. If you need more than 5 requirements for the mixin, use the MixinAny function instead, which is an exact analog of Mixin, but without this type-level protection for requirements mismatch.

It is possible to simplify the type of the base argument a bit, by using the ClassUnion helper. However, it seems in certain edge cases it may lead to compilation errors. If your typization scenarios are not so complex you should give it a try. Using the ClassUnion helper, the Mixin3 can be defined as:

class Mixin3 extends Mixin(
    [ Mixin1, Mixin2 ],
    (base : ClassUnion<typeof Mixin1, typeof Mixin2>) =>

    class Mixin3 extends base {
    }
){}

As you notice, repeating the listing of the requirements is somewhat verbose. Suggestions how the pattern can be improved are very welcome.

Mixin instantiation, mixin constructor + instanceof

You can instantiate any mixin class directly by using its constructor:

const instance1 = new Mixin1()
const instance2 = new Mixin2()

As explained in details here, the mixin constructor should accept a variable number of arguments with the any type. This is simply because the mixin is supposed to be applicable to any other base class which may have its own type of the constructor arguments.

class Mixin2 extends Mixin(
    [ Mixin1 ],
    (base : AnyConstructor<Mixin1, typeof Mixin1>) => {
        class Mixin2 extends base {
            prop2 : string

            constructor (...args: any[]) {
                super(...args)
                this.prop2 = ''
            }
        }
        return Mixin2
    }
){}

In other words, it is not possible to provide any type-safety for mixin instantiation using the regular class constructor.

However, if we change the way we create class instances a little, we can get the type-safety back. For that, we need to use a “uniform” class constructor – a constructor which has the same form for all classes. The Base class provides such a constructor as its static new method. The usage of the Base class is not required – you can use any other base class.

The instanceof operator works as expected for instances of the mixin classes. It also takes into account all the requirements. For example:

const instance2 = new Mixin2()

const isMixin2 = instance2 instanceof Mixin2 // true
const isMixin1 = instance2 instanceof Mixin1 // true, since Mixin2 requires Mixin1

Manual class derivation

You have defined a mixin using the Mixin function. Now you want to apply it to some base class to get the “specific” class to be able to instantiate it. As described above – you don’t have to, you can instantiate it directly.

Sometimes however, you still want to derive the class “manually”. For that, you can use the static methods mix and derive, available on all mixins.

The mix method provides direct access to the mixin lambda. It does not take requirements into account – that is the implementor’s responsibility. The derive method is something like an “accumulated” mixin lambda – a mixin lambda with all requirements.

Both mix and derive provide a reasonably typed outcome.

class Mixin1 extends Mixin(
    [],
    (base : AnyConstructor) =>

    class Mixin1 extends base {
        prop1        : string
    }
){}

class Mixin2 extends Mixin(
    [ Mixin1 ],
    (base : AnyConstructor<Mixin1, typeof Mixin1>) =>

    class Mixin2 extends base {
        prop2        : string
    }
){}

const ManualMixin1 = Mixin1.mix(Object)
const ManualMixin2 = Mixin2.mix(Mixin1.mix(Object))

const AnotherManualMixin1 = Mixin1.derive(Object)
const AnotherManualMixin2 = Mixin2.derive(Object)

Generics

Using generics with mixins is tricky since TypeScript does not have higher-kinded types and type inference for generics. Still some form of generic arguments is possible, using the interface merging trick.

Here’s the pattern:

class Duplicator<Element> extends Mixin(
    [],
    (base : AnyConstructor) =>

    class Duplicator extends base {
        Element                 : any

        duplicate (value : this[ 'Element' ]) : this[ 'Element' ][] {
            return [ value, value ]
        }
    }
){}

interface Duplicator<Element> {
    Element : Element
}

const dup = new Duplicator<boolean>()

dup.duplicate('foo') // TS2345: Argument of type '"foo"' is not assignable to parameter of type 'boolean'.

In the example above, we have defined a generic argument Element for the outer mixin class, but in fact, that argument is not used anywhere in the nested class definition in the mixin lambda. Instead, in the nested class, we define a property Element, which plays the role of a generic argument.

Mixin class methods then can refer to the generic type as this[ 'Element' ].

The generic arguments of the outer and nested classes are tied together in the additional interface declaration, which by TypeScript rules is merged together with the class definition. In this declaration, we specify that property Element has the type of the Element generic argument.

Limitations

The most important limitation we found (which affects the old pattern as well) is the compilation error, which is issued for private and protected methods when compiling with declarations emitting (*.d.ts file generation).

This is a well-known problem in the TypeScript world – the *.d.ts files do not represent the internal data structures of the TypeScript compiler well. Instead they use a simplified syntax, optimized for human editing. This is the reason why the compiler is giving false positives compilation errors in the incremental mode – as incremental mode uses *.d.ts files internally.

Unfortunately, it seems the TypeScript team does not perceive this as a problem, otherwise it would be fixed long ago. For me this is very strange – having a compiler that gives no errors in normal compilation mode and that gives errors in incremental compilation mode just means that the compiler is buggy. Of course it is just a matter of prioritization and probably the TypeScript team has different priorities. Perhaps it is time to reconsider the importance of this problem, and if you are part of the TypeScript team, please raise this topic in your internal discussions.

This issue can be a show-stopper for anyone using declaration files (usually for publishing). Keep in mind though, that you can always publish actual TypeScript sources along with the generated JavaScript files instead of publishing JavaScript + declarations files.

Further work

We still see this pattern as evolving and encourage experiments and feedback. Areas of further research:

  • Obviously, the repeated requirements listing is sub-optimal. Ideally requirements should be listed only once as an array, and other types should be derived from it. However it seems this would require type inference on generic, which is not supported currently.
  • Generics are still a hackish trick which needs improvement.
  • It feels like the mixin pattern should be supported as a language construct, for example:
mixin Mixin3 extends Mixin1, Mixin2 {
}

It is probably possible to “desugar” this construct to a plain TypeScript using TypeScript transformer. This is probably the holy grail, but requires research.

Conclusion

The presented pattern above solves both the recursive references and manual requirements listing problems. It scales well for big number of mixins. The pattern still provides direct access to the mixin lambda function, which one can use for full manual control over the definitions.

We have successfully used the new pattern for modelling our Gantt project plan data domain, which contains over 30 mixins with precisely formulated business requirements. Stay tuned for future evangelistic post about TypeScript mixins.

Leave a Reply

avatar
  Subscribe  
Notify of