Created
December 9, 2025 21:05
-
-
Save TorbenKoehn/64662d06383d0f50e89b9f228881de86 to your computer and use it in GitHub Desktop.
TypeScript Traits Concept
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /******************************************** | |
| * Base trait system | |
| * | |
| * Author: Torben Köhn <t.koehn@outlook.com> | |
| * License: MIT | |
| * | |
| *******************************************/ | |
| /** | |
| * Symbol to keep implemented trait references on the instance. | |
| * | |
| * Required for instanceof checks. | |
| */ | |
| const TraitSymbol = Symbol('traits') | |
| /** | |
| * A class that uses traits. | |
| */ | |
| export type TraitConsumer = { readonly [TraitSymbol]: Trait[] } | |
| export const isTraitConsumer = (value: unknown): value is TraitConsumer => { | |
| return ( | |
| typeof value === 'object' && | |
| value !== null && | |
| TraitSymbol in value && | |
| Array.isArray((value as TraitConsumer)[TraitSymbol]) | |
| ) | |
| } | |
| /** | |
| * A trait is a class that can be mixed into other classes to provide additional functionality. | |
| * | |
| * See it as an interface with implementation. | |
| * | |
| * Traits MUST be abstract classes and cannot be instantiated directly. | |
| * Traits MUST NOT have a constructor with parameters. | |
| * | |
| * Abstract methods can be used and will be propagated to the consuming class properly. | |
| */ | |
| export abstract class Trait { | |
| /** | |
| * Parameterless standard constructor for traits. | |
| */ | |
| constructor() {} | |
| /** | |
| * Allows instanceof checks for traits. | |
| * | |
| * @param instance The instance to check | |
| * @returns True if the instance implements the trait | |
| */ | |
| static [Symbol.hasInstance](instance: unknown) { | |
| return ( | |
| isTraitConsumer(instance) && | |
| instance[TraitSymbol].some((traitInstance) => traitInstance.constructor === this) | |
| ) | |
| } | |
| } | |
| /** | |
| * A constructor type for traits. | |
| * | |
| * Notice traits MUST be abstract classes. See {@link Trait}. | |
| */ | |
| export type TraitConstructor = abstract new () => Trait | |
| /** | |
| * Type helper to convert a union type to an intersection type. | |
| * | |
| * It will turn `A | B | C` into `A & B & C`. | |
| */ | |
| type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void | |
| ? I | |
| : never | |
| /** | |
| * Allows to create a class that implements one or more traits. | |
| * | |
| * Arguments need to be {@link TraitConstructor} types. | |
| * | |
| * Example: | |
| * ```ts | |
| * class Clone<T> extends Trait { | |
| * abstract clone(): T | |
| * } | |
| * | |
| * class Vector2 extends traits(Clone<Vector2>) { | |
| * constructor( | |
| * public x: number = 0, | |
| * public y: number = 0 | |
| * ) { | |
| * super() | |
| * } | |
| * override clone(): Vector2 { | |
| * return new Vector2(this.x, this.y) | |
| * } | |
| * } | |
| * ``` | |
| * | |
| * @param traits The trait constructors to implement | |
| * @returns A base class implementing the given traits | |
| */ | |
| export const traits = <const T extends readonly TraitConstructor[]>(...traits: T) => { | |
| return class implements TraitConsumer { | |
| readonly [TraitSymbol]: Trait[] = [] | |
| constructor() { | |
| for (const CurrentTrait of traits) { | |
| const traitInstance = Reflect.construct(CurrentTrait, []) | |
| const properties = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(traitInstance)) | |
| for (const [key, descriptor] of Object.entries(properties)) { | |
| if (key === 'constructor') { | |
| continue | |
| } | |
| Object.defineProperty(this, key, descriptor) | |
| } | |
| this[TraitSymbol].push(traitInstance) | |
| } | |
| } | |
| } as { | |
| new (): UnionToIntersection<InstanceType<T[number]>> | |
| } | |
| } | |
| /*************************** | |
| * Example traits and usage | |
| ***************************/ | |
| abstract class Serialize extends Trait { | |
| // Abstract methods work properly | |
| abstract serialize(): unknown | |
| } | |
| abstract class Deserialize extends Trait { | |
| abstract deserialize(data: unknown): void | |
| } | |
| abstract class Clone<T> extends Trait { | |
| abstract clone(): T | |
| } | |
| abstract class Stringify extends Trait { | |
| // Full implementations are no problem | |
| stringify(): string { | |
| return `[Object ${this.constructor.name}]` | |
| } | |
| } | |
| abstract class Print extends Trait { | |
| printId(): void { | |
| // Other traits can be checked via instanceof | |
| if (this instanceof Stringify) { | |
| console.log(`ID: stringify ${this.stringify()}`) | |
| return | |
| } | |
| console.log(`ID: toString ${this.toString()}`) | |
| } | |
| } | |
| // Using traits(A, B, C, ...) to "implement" traits | |
| class Foo extends traits(Serialize, Deserialize, Clone<Foo>, Stringify, Print) { | |
| override serialize(): unknown { | |
| return {} | |
| } | |
| override deserialize(data: unknown): void { | |
| // no-op | |
| } | |
| override clone(): Foo { | |
| return new Foo() | |
| } | |
| } | |
| const foo = new Foo() | |
| foo.printId() // ID: stringify [Object Foo] | |
| console.log(`foo instanceof Serialize: ${foo instanceof Serialize}`) // true | |
| console.log(`foo instanceof Deserialize: ${foo instanceof Deserialize}`) // true | |
| console.log(`foo instanceof Clone: ${foo instanceof Clone}`) // true | |
| console.log(`foo instanceof Print: ${foo instanceof Foo}`) // true | |
| /*************************** | |
| * Stateful example | |
| ***************************/ | |
| abstract class Counter extends Trait { | |
| // Fields/state is always propagated to the consuming class | |
| abstract count: number | |
| increment(): number { | |
| this.count++ | |
| return this.count | |
| } | |
| decrement(): number { | |
| this.count-- | |
| return this.count | |
| } | |
| } | |
| class StatefulFoo extends traits(Counter) { | |
| count = 0 | |
| logAndIncrement(): void { | |
| console.log(`Current count: ${this.count}`) | |
| this.increment() | |
| } | |
| logAndDecrement(): void { | |
| console.log(`Current count: ${this.count}`) | |
| this.decrement() | |
| } | |
| } | |
| const a = new StatefulFoo() | |
| a.logAndIncrement() // Current count: 0 | |
| a.logAndIncrement() // Current count: 1 | |
| a.logAndIncrement() // Current count: 2 | |
| const b = new StatefulFoo() | |
| b.logAndDecrement() // Current count: 0 | |
| b.logAndDecrement() // Current count: -1 | |
| b.logAndDecrement() // Current count: -2 |
Um Trait-Requirements zu implementieren, könnte man ein Symbol RequiredTraitsSymbol hinzufügen und dem Typ trait einen static Member geben
static [RequiredTraitsSymbol] = [] as readonly TraitConstructor[]dann ein Typ-Prädikat bauen
type RequirementsSatisfied<Requirements extends readonly TraitConstructor[], Existing> =
Requirements extends readonly [infer Head, ...infer Tail]
? Head extends TraitConstructor & Existing
? RequirementsSatisfied<
Tail extends readonly TraitConstructor[] ? Tail : never,
Existing
>
: false
: true
type SatisfiesTraitOrder<Traits extends readonly TraitConstructor[], Existing = never> =
Traits extends readonly [infer Head, ...infer Tail]
? Head extends TraitConstructor
? RequirementsSatisfied<
Head[typeof RequiredTraitsSymbol] extends readonly TraitConstructor[] ? Head[typeof RequiredTraitsSymbol] : never,
Existing
> extends true
? SatisfiesTraitOrder<
Tail extends readonly TraitConstructor[] ? Tail : never,
Existing | Head
>
: false
: false
: trueund dann in Zeile 122 - 124 stattdessen so was wie
} as SatisfiesTraitOrder<T> extends true ? {
new (): Intersect<{[Index in keyof T]: InstanceType<T[Index]>}>
} : neverschreiben. Wenn man dann z.B.
abstract class Print extends Stringify {
static [RequiredTraitsSymbol] = [Stringify] as const
...
}hat und dann versucht, von trait(Print) zu extenden, bekommt man einen Type-Error. Bei trait(Stringify, Print) aber nicht. Man kann dann auch zur Runtime Reflection über die Abhängigkeiten machen, indem man einfach die RequiredTraitsSymbol-Property ausliest.
Ein Fix für das Typpredikat, damit subtypes von Requirements auch akzeptiert werden:
type RequirementSatisfied<Requirement extends TraitConstructor, Existing extends readonly TraitConstructor[]> =
Existing extends readonly [infer Head, ...infer Tail]
? Head extends Requirement
? true
: RequirementSatisfied<
Requirement,
Tail extends readonly TraitConstructor[] ? Tail : never
>
: false
type RequirementsSatisfied<Requirements extends readonly TraitConstructor[], Existing extends readonly TraitConstructor[]> =
Requirements extends readonly [infer Head, ...infer Tail]
? Head extends TraitConstructor
? RequirementSatisfied<Head, Existing> extends true
? RequirementsSatisfied<
Tail extends readonly TraitConstructor[] ? Tail : never,
Existing
>
: false
: false
: true
type SatisfiesTraitOrder<TraitsSuffix extends readonly TraitConstructor[], TraitsPrefix extends readonly TraitConstructor[] = []> =
TraitsSuffix extends readonly [infer Head, ...infer Tail]
? Head extends TraitConstructor
? RequirementsSatisfied<
Head[typeof RequiredTraitsSymbol] extends readonly TraitConstructor[] ? Head[typeof RequiredTraitsSymbol] : never,
TraitsPrefix
> extends true
? SatisfiesTraitOrder<
Tail extends readonly TraitConstructor[] ? Tail : never,
readonly [...TraitsPrefix, Head]
>
: false
: false
: trueDamit kann man dann auch sowas hier machen
abstract class Print extends traits(Stringify, Serialize) {
static [RequiredTraitsSymbol] = [Stringify, Serialize] as const
printId(): void {
console.log(`ID: stringify ${this.stringify()} and ${this.serialize()}`)
}
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Coole implementierung, vor allem mit dem
isinstance. Was ich noch hinzufügen würde, ist die Möglichkeit, traits zu extenden. Also z.B.Dazu die umgekehrte prototype chain
und dann in Zeile 55 stattdessen
und in Zeile 111 dann die method resolution order implementieren
Außerdem ist es vielleicht sauberer so einen Typen hier zu benutzen
Um in Zeile 123 dann stattdessen
zu schreiben.