G'day:
I've been working through TypeScript classes, and today I hit mixins. For those unfamiliar, mixins are a pattern for composing behavior from multiple sources - think Ruby's modules or PHP's traits. They're basically "poor person's composition" - a way to share behavior between classes when you can't (or won't) use proper dependency injection.
I think they're a terrible pattern. If I need shared behavior, I'd use actual composition - create a proper class and inject it as a dependency. But I'm not always working with my own code, and mixins do exist in the wild, so here we are.
The TypeScript mixin implementation is interesting though - it's built on generics and functions that return classes, which is quite different from the prototype-mutation approach you see in JavaScript. And despite my reservations about the pattern itself, understanding how it works turned out to be useful for understanding TypeScript's type system better.
The basic pattern
TypeScript mixins aren't about mutating prototypes at runtime (though you can do that in JavaScript). They're functions that take a class and return a new class that extends it.
For this example, I wanted a mixin that would add a flatten() method to any class - something that takes all the object's properties and concatenates their values into a single string. Not particularly useful in real code, but simple enough to demonstrate the mechanics without getting lost in business logic.
type Constructor = new (...args: any[]) => {}
function applyFlattening<TBase extends Constructor>(Base: TBase) {
return class Flattener extends Base {
flatten(): string {
return Object.entries(this).reduce(
(flattened: string, [_, value]): string => {
return flattened + String(value)
},
''
)
}
}
}
(from mixins.ts)
That Constructor type is saying "anything that can be called with new and returns an object". The mixin function takes a class that matches this type and returns a new anonymous class that extends the base class with additional behavior.
You can then apply it to any class:
export class Name {
constructor(
public firstName: string,
public lastName: string
) {}
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
export const FlattenableName = applyFlattening(Name)
FlattenableName is now a class that has everything Name had plus the flatten() method. TypeScript tracks all of this at compile time, so you get proper type checking and autocomplete for both the base class members and the mixin methods.
The generics bit
The confusing part (at least initially) is this bit:
function applyFlattening<TBase extends Constructor>(Base: TBase)
Without understanding generics, this is completely opaque. The <TBase extends Constructor> is saying "this function is generic over some type TBase, which must be a constructor". The Base: TBase parameter then uses that type.
This lets TypeScript track what specific class you're mixing into. When you call applyFlattening(Name), TypeScript knows that TBase is specifically the Name class, so it can infer that the returned class has both Name's properties and methods plus the flatten() method.
Without generics, TypeScript would only know "some constructor was passed in" and couldn't give you proper type information about what the resulting class actually contains. The generic parameter preserves the type information through the composition.
I hadn't covered generics properly before hitting this (it's still on my todo list), which made the mixin syntax particularly cryptic. But the core concept is straightforward once you understand that generics are about preserving type information as you transform data - in this case, transforming a class into an extended version of itself.
Using the mixed class
Once you've got the mixed class, using it is straightforward:
const flattenableName: InstanceType<typeof FlattenableName> =
new FlattenableName('Zachary', 'Lynch')
expect(flattenableName.fullName).toEqual('Zachary Lynch')
const flattenedName: string = flattenableName.flatten()
expect(flattenedName).toEqual('ZacharyLynch')
(from mixins.test.ts)
The InstanceType<typeof FlattenableName> bit is necessary because FlattenableName is a value (the constructor function), not a type. typeof FlattenableName gives you the constructor type, and InstanceType<...> extracts the type of instances that constructor creates.
Once you've got an instance, it has both the original Name functionality (the fullName getter) and the new flatten() method. The mixin has full access to this, so it can see all the object's properties - in this case, firstName and lastName.
Constraining the mixin
The basic Constructor type accepts any class - it doesn't care what properties or methods the class has. But you can constrain mixins to only work with classes that have specific properties:
type NameConstructor = new (
...args: any[]
) => {
firstName: string
lastName: string
}
function applyNameFlattening<TBase extends NameConstructor>(Base: TBase) {
return class NameFlattener extends Base {
flatten(): string {
return this.firstName + this.lastName
}
}
}
(from mixins.ts)
The NameConstructor type specifies that the resulting instance must have firstName and lastName properties. Now the mixin can safely access those properties directly - TypeScript knows they'll exist.
You can't constrain the constructor parameters themselves - that ...args: any[] is mandatory for mixin functions. TypeScript requires this because the mixin doesn't know what arguments the base class constructor needs. You can only constrain the instance type (the return type of the constructor).
This means a class like this won't work with the constrained mixin:
export class ShortName {
constructor(public firstName: string) {}
}
// This won't compile:
// export const FlattenableShortName = applyNameFlattening(ShortName)
// Argument of type 'typeof ShortName' is not assignable to parameter of type 'NameConstructor'
TypeScript correctly rejects it because ShortName doesn't have a lastName property, and the mixin's flatten() method needs it.
Chaining multiple mixins
You can apply multiple mixins by chaining them - pass the result of one mixin into another:
function applyArrayifier<TBase extends Constructor>(Base: TBase) {
return class Arrayifier extends Base {
arrayify(): string[] {
return Object.entries(this).reduce(
(arrayified: string[], [_, value]): string[] => {
return arrayified.concat(String(value).split(''))
},
[]
)
}
}
}
export const ArrayableFlattenableName = applyArrayifier(FlattenableName)
(from mixins.ts)
Now ArrayableFlattenableName has everything from Name, plus flatten() from the first mixin, plus arrayify() from the second mixin:
const transformableName: InstanceType<typeof ArrayableFlattenableName> =
new ArrayableFlattenableName('Zachary', 'Lynch')
expect(transformableName.fullName).toEqual('Zachary Lynch')
const flattenedName: string = transformableName.flatten()
expect(flattenedName).toEqual('ZacharyLynch')
const arrayifiedName: string[] = transformableName.arrayify()
expect(arrayifiedName).toEqual('ZacharyLynch'.split(''))
(from mixins.test.ts)
TypeScript correctly infers that all three sets of functionality are available on the final class. The type information flows through each composition step.
Why not just use composition?
Right, so having learned how mixins work in TypeScript, I still think they're a poor choice for most situations. If you need shared behavior, use actual composition:
class Flattener {
flatten(obj: Record<string, unknown>): string {
return Object.entries(obj).reduce(
(flattened, [_, value]) => flattened + String(value),
''
)
}
}
class Name {
constructor(
public firstName: string,
public lastName: string,
private flattener: Flattener
) {}
flatten(): string {
return this.flattener.flatten(this)
}
}
This is clearer about dependencies, easier to test (inject a mock Flattener), and doesn't require understanding generics or the mixin pattern. The behavior is in a separate class that can be reused anywhere, not just through inheritance chains.
Mixins make sense in languages where you genuinely can't do proper composition easily, or where the inheritance model is the primary abstraction. But TypeScript has first-class support for dependency injection and composition. Use it.
The main legitimate use case I can see for TypeScript mixins is when you're working with existing code that uses them, or when you need to add behavior to classes you don't control. Otherwise, favor composition.
The abstract class limitation
One thing you can't do with mixins is apply them to abstract classes. The pattern requires using new Base(...) to instantiate and extend the base class, but abstract classes can't be instantiated - that's their whole point.
abstract class AbstractBase {
abstract doSomething(): void
}
// This won't work:
// const Mixed = applyMixin(AbstractBase)
// Cannot create an instance of an abstract class
The workarounds involve either making the base class concrete (which defeats the purpose of having it abstract), or mixing into a concrete subclass instead of the abstract parent. Neither is particularly satisfying.
This is a fundamental incompatibility between "can't instantiate" (abstract classes) and "must instantiate to extend" (the mixin pattern). It's another reason to prefer composition - you can absolutely inject abstract dependencies through constructor parameters without these limitations.
What I learned
TypeScript mixins are functions that take classes and return extended classes. They use generics to preserve type information through the composition, and TypeScript tracks everything at compile time so you get proper type checking.
The syntax is more complicated than it needs to be (that type Constructor = new (...args: any[]) => {} bit), and you need to understand generics before any of it makes sense. The InstanceType<typeof ClassName> dance is necessary because of how TypeScript distinguishes between constructor types and instance types.
You can constrain mixins to only work with classes that have specific properties, and you can chain multiple mixins together. But you can't use them with abstract classes, and they're generally a worse choice than proper composition for most real-world scenarios.
I learned the pattern because I'll encounter it in other people's code, not because I plan to use it myself. If I need shared behavior, I'll use dependency injection and composition like a sensible person. But now at least I understand what's happening when I see const MixedClass = applyMixin(BaseClass) in a codebase.
The full code for this investigation is in my learning-typescript repository. Thanks to Claudia for helping work through the type constraints and the abstract class limitation, and for assistance with this write-up.
Righto.
--
Adam