Layer reusable behavior onto a class without multiple inheritance
A TypeScript class has exactly one base class, yet several unrelated classes in the codebase need the same timestamping behavior. Implement Timestamped as a mixin.
Requirements: Timestamped(Base) returns a class that keeps every member and constructor parameter of Base, adds createdAt: Date set on construction and a touch(): void method, and composes with other mixins, e.g. class Doc extends Timestamped(Serializable(Entity)) {}. The produced class's own members must not be typed any.
type Ctor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Ctor>(Base: TBase) {
// your code here
}
Write the implementation.
A mixin is a function that takes a constructor type and returns a class expression extending it: function Stamped<T extends Ctor>(B: T) { return class extends B { at = new Date() }; }. The constructor constraint forwards the base's arguments, and because the result is itself a class, mixin calls nest.
- ✗Copying methods onto a prototype and expecting the type to gain them
- ✗Constraining the base as a plain object type instead of a constructor type
- ✗Dropping the base's constructor arguments instead of forwarding
...args
- →How would you require the base to already have a member, e.g.
id: string, before the mixin applies? - →Why does giving the mixin an explicit return type usually make composition worse?
The solution
type Ctor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Ctor>(Base: TBase) {
return class Timestamped extends Base {
createdAt: Date = new Date();
updatedAt: Date | null = null;
touch(): void {
this.updatedAt = new Date();
}
};
}
Three parts, all load-bearing:
TBase extends Ctor— the type parameter is constrained to a constructor type, not an object type. That is the only wayclass ... extends Baseis legal: you can only extend an expression with a construct signature....args: any[]insideCtor— this is what lets the derived class accept the base's constructor parameters without enumerating them. The mixin's own members (createdAt,touch) stay precisely typed.- It returns a class expression, not an object. That is why the result is itself usable in
extends— mixins nest.
class Entity {
constructor(public id: string) {}
}
class Doc extends Timestamped(Entity) {}
const d = new Doc("d-1"); // Entity's constructor arguments were forwarded
d.id; // string — base member survives
d.createdAt; // Date — mixin member
d.touch();
⚠️ Do not give the mixin an explicit return type: it is inferred as an anonymous class, and that inference is exactly what fuses the base's members with the mixin's. An annotation such as : Ctor<Timestamped> erases Base's members.
⚠️ Object.assign(Doc.prototype, {...}) attaches the methods at runtime, but the class type never learns about them — that is precisely the mistake the function-over-a-class form exists to avoid.