Class Composition in TypeScript
Software design is important. It is the process of creating a system that meets the requirements and objectives for a certain project.
To design a good software, we should consider various factors such as scalability and maintainability, and make informed choices regarding technologies like cloud services, programming languages, and frameworks.
In an abstract manner we have paradigms. For example, in object-oriented programming (OOP) the primary focus is on creating reusable, modular code by organizing it into objects that interact with each other.
Every paradigm is expressed by principles. Following that example, the OOP paradigm principles would be encapsulation, inheritance, polymorphism and abstraction.
In a concrete manner we have patterns. A design pattern is a reusable solution that can be applied to commonly occurring problems in a particular context of software design. For example, a singleton pattern stipulates that classes can be instantiated only once and can be accessed globally. So singletons pretty much solve the problem of managing global states in an application.
To learn about JavaScript's design patterns go to this free online resource.
TypeScript's versatility
Programming languages are often designed with one paradigm in mind but eventually evolve to support others. For instance, JavaScript was originally created as a prototype-based language and it is nowadays considered to be a multi-paradigm programming language.
It can define regular functions to perform tasks, which aligns with procedural programming. But it can also define constructor functions or ES6 built-in classes to facilitate object-oriented programming.
It treats functions as first-class citizens, meaning they can be assigned to variables, passed as arguments to other functions or returned from functions. Additionally, the use of closures and avoiding mutable data makes JavaScript into an excellent functional programming language.
In the context of web development, JavaScript is well-suited for event-driven programming and asynchronous programming, since events should contain callback functions. To learn about JavaScript's non-blocking operations, the event loop, the call stack and how the Web APIs work, go to this amazing talk by Philip Roberts. Of course this could be applied to Node.js in the server side, but with C++ APIs instead.
Reactive programming is also possible in JavaScript through libraries like RxJS. It allows us to handle asynchronicity with streams, which are sequences of events that change over time.
TypeScript, being a superset of JavaScript, supports all the mentioned paradigms, and enhances them with static typing features. Type-driven development (TDD) is not exactly a paradigm but a development methodology or approach that emphasizes writing code guided by types. However, this methodology enforces the interface-based programming paradigm.
So now we should ponder the question: what paradigm(s) should we use in a TypeScript project?
Many of the criteria for selecting programming paradigms in a TypeScript backend project would be similar to those in just any backend project, regardless of the programming language.
However, many frontend applications, whether they are single-page applications (SPAs) or multiple-page applications (MPAs), are structured using a component-based architecture.
But component-based development (CBD) is not a paradigm because it has no defined principles. It is an architectural approach that recommends breaking down code into reusable elements that encapsulate both visual logic and associated behavior.
Due to the aforementioned reasons, we will intentionally focus on frontend projects, assuming they are built using component-based architectures, and we will omit considerations related to backend projects.
Inheritance and Polymorphism
A fundamental principle in software development is D.R.Y. (Don't Repeat Yourself), which emphasizes the importance of avoiding duplication and promoting code reusability. D.R.Y. aligns closely with the SOLID Single Responsibility Principle, another essential concept in software design.
In CBD, it is very good to stay "dry" and apply a separations of concerns to our TypeScript classes. Take the typical example of the Animal
class:
abstract class Animal {
abstract makeSound(): void;
move(distanceInMeters: number): void {
console.log(`This animal moved ${distanceInMeters} meters.`);
}
}
class Dog extends Animal {
makeSound(): void {
console.log('The dog barks.');
}
}
class Cat extends Animal {
makeSound(): void {
console.log('The cat meows.');
}
}
const dog = new Dog();
const cat = new Cat();
dog.makeSound(); // Output: The dog barks.
dog.move(10); // Output: This animal moved 10 meters.
cat.makeSound(); // Output: The cat meows.
cat.move(5); // Output: This animal moved 5 meters.
Dog
and Cat
are derived classes and therefore they are using an inheritance mechanism. The makeSound()
method is abstract, so polymorphism is applied there.
While inheritance and polymorphism are powerful concepts in OOP, they can also introduce challenges in TypeScript frontend projects.
As the project grows, the hierarchy introduced by inheritance may end up in a very complex chain, hard to understand, maintain and modify. This can lead to overgeneralization and rigid architectures. Moreover, changes in one class may require modifications in multiple subclasses.
On the other hand, polymorphic method calls may involve runtime method resolution, which can impact performance, especially in performance-sensitive frontend applications. And besides: these abstractions make the code very difficult to debug.
Even the Mozilla Foundation has some thoughts for us on why we should avoid inheritance. Think of it this way: a Snake
would certainly move but it would not make any sound, while still being an Animal
. And according to the SOLID Liskov Substitution Principle objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
So according to this, in order to cover a complete animal classification and fit snakes into it, we would have to add a level of abstraction in between for NoisyAnimal
and SilentAnimal
which would create then two hierarchy subchains of animals with almost the same characteristics and we would have to repeat many methods in the subclasses of both chains.
This would inevitably lead to the feared boilerplate accumulation and, as a result, would makes us betray the D.R.Y. principle.
Composition in frameworks
Inheriting from other classes would not suite a component-based architecture, since a class can only be extended by one other class, and not by several. We need a more flexible approach, like the mixin pattern, which replaces inheritance with composition.
Mixins are objects that can add reusable functionality to another object or class, without using inheritance. They cannot be used on their own; instead, they provide composition to other objects or classes.
This pattern was popular for some time in the React world. They used a composition technique called Higher-Order Components (HOC).
A React HOC is a function that takes a component and returns a new component.
const EnhancedComponent = higherOrderComponent(WrappedComponent);
HOCs were commonly used to share stateful logic so they have fallen into disuse since the arrival of React Hooks.
Nonetheless, the idea of HOCs is interesting and this mixin pattern would perfectly fit to component classes written in TypeScript.
Now, what frontend technologies can actually do that?
Let us not forget that React and React-like frameworks declare components as functions, not as classes, even if they are written in tsx
.
Same goes for Vue, since we need to use the defineComponent()
function to be able to infer the types. If you still want to write Vue components as TypeScript classes, you can use a third party library such as vue-facing-decorator. But these kind of packages are no longer recommended as per Vue 3.
With Svelte it is also not possible, since its syntax is very particular: it declares props, slots, and events within a pseudo-html script tag. You can add TypeScript type checking to that by adding lang="ts"
to the tag, but never embody the component in a class.
We do find good TypeScript class implementation in the two main web component libraries: Lit and Stencil.
Angular, which pioneered TypeScript by writing its core in it, not only provides excellent TypeScript class implementation but also a composition API through host directives that allows us to apply Angular directives to a component's host element from within this component's TypeScript class.
Although this can be a very powerful composition tool, it does not allow us to use properties or methods from any host directive directly in the component class, since each class gets instantiated separately. Check it out:
@Component({
standalone: true,
[...],
hostDirectives: [MenuBehaviorDirective],
})
export class AdminMenuComponent {
menuOpen: boolean = inject(MenuBehaviorDirective).menuOpen;
}
It forces us to redeclare the property in the component by injecting it. This would be good for computed properties, but if not so good for interoperability or to remove the boilerplate.
In the end, there are only 2 effective ways of doing TypeScrips class composition:
State of decorators
TypeScript decorators are not yet based on an approved implementation. Proposing and standardizing changes to ECMAScript features involves several stages in the TC39 process:
Stage 0: Strawman. For initial ideas and proposals that have not been formally presented to the committee yet.
Stage 1: Proposal. Must include a description of the problem being solved, a high-level API, and a list of open issues.
Stage 2: Draft. The proposal is developed further and a detailed specification is provided.
Stage 3: Candidate. At this stage, the proposal should include tests, examples, and a compatibility analysis.
Stage 4: Finished. Formally approved by TC39. It can be implemented by JavaScript engines.
Many proposals do not advance beyond stage 1 in this process. For example, the observable proposal has been sitting at that stage for quite a while. On the other hand, ECMAScript decorators are making good progress. They are currently at stage 3, and promising to the approved soon.
It's important to note that stage 3 decorator support is available starting from TypeScript 5.0. So versions prior to 5 carry an experimental stage 2 implementation. Therefore, if you are using those lower versions, it is advisable to avoid implementing decorators in your project to ensure compatibility and stability.
The new decorator specification in TypeScript 5.0 can be used out of the box without relying on the experimentalDecorators
and emitDecoratorMetadata
compiler options, as these ones refer to the previous implementation.
Decorating classes
Decorators are essentially functions that can be applied to classes or their members (properties or methods). These functions receive the value of the class or class member and return it modified or fully replaced with a value of the same type.
They are really fascinating and would deserve their own article. The possibilities to what we can do with them are endless. We shall focus our attention on their capability of providing class composition by decorating TypeScript classes.
Let us say we have an app component and we want to add to it a property called components
that specifies a map of components to be rendered. We would like to introduce that property through a configuration which would be external to the app component, and each of those components should carry a value that tells us if the component is going to be rendered eagerly or lazily. Here is the code:
type Constructor<T = {}> = new (...args: any[]) => T;
interface ComposedApp {
components: RenderedComponentMap;
}
type RenderedComponentMap = Map<any, RenderingStrategy>;
type RenderingStrategy = 'eager' | 'lazy';
const eagerComponent: any = {};
const lazyComponent: any = {};
const components: RenderedComponentMap = new Map([
[eagerComponent, 'eager'],
[lazyComponent, 'lazy'],
]);
function RenderingDecorator<B extends Constructor>(
renderingApp: Pick<ComposedApp, 'components'>
): (Base: B, context: ClassDecoratorContext) => B {
return (Base: B, context: ClassDecoratorContext) => {
return context.kind === 'class'
? class extends Base {
components: RenderedComponentMap = renderingApp.components;
}
: Base;
};
}
interface AppComponent extends ComposedApp {}
@RenderingDecorator({
components,
})
class AppComponent {
property = 'property';
method(): string {
return 'method';
}
}
const component = new AppComponent();
console.log('AppComponent property:::', component.property);
console.log('AppComponent method():::', component.method());
console.log(
'AppComponent components.get(eagerComponent) <---:::',
component.components.get(eagerComponent)
);
console.log(
'AppComponent components.get(eagerComponent) <---:::',
component.components.get(lazyComponent)
);
The Constructor
type is essential to work with class composition. It allows us to specify the generic constructor of the base class that is going to be modified or replaced, in this case by a decorator.
type Constructor<T = {}> = new (...args: any[]) => T;
With decorators, we will also need a model that tells TypeScript about any extra properties or methods that we may add to the decorated class. Otherwise our IDE will yell at us.
interface ComposedApp {
components: RenderedComponentMap;
}
Then we can declare the pertinent models and the components to be rendered.
Let us take a look at the decorator itself:
function RenderingDecorator<B extends Constructor>(
renderingApp: Pick<ComposedApp, 'components'>
): (Base: B, context: ClassDecoratorContext) => B {
return (Base: B, context: ClassDecoratorContext) => {
return context.kind === 'class'
? class extends Base {
components: RenderedComponentMap = renderingApp.components;
}
: Base;
};
}
It is a function that returns a factory function, in this case one that returns a TypeScript class that extends a base class.
We pass the rendering app object with the components in it as a parameter of the RenderingDecorator
function. Then the inner factory function would take 2 expected parameters:
Base: the decorated class in this case, although, as soon as we introduce composition by mixing decorators, that base can be either the decorated class or another decorator function. We will see that.
context: an object containing information about the base (
kind
andname
), itsmetadata
and anaddInitializer()
function that we could use to run arbitrary code after that base has been defined, in order to finish setting it up. For the time being, we should ignore the metadata information. Even though the TypeScript team has included it as part of the decorator proposal, it is actually an extension of it and we cannot fully rely on it yet. The rest of the data can be useful though. For example,kind
allows us to check if the decorator is indeed added to a class and not to a class member.
We can decorate the app component class this way:
interface AppComponent extends ComposedApp {}
@RenderingDecorator({
components,
})
class AppComponent {}
Once we instantiate AppComponent
we will be able to access not only its specific properties but also the ones added by the decorator.
Composition with decorators
In the previous example we are adding new properties to the app component, but we are not really doing composition yet.
Let us say that we want to add 2 more decorators that contain:
The menu functionality suggested when talking about Angular's directive composition.
A global state functionality with a RxJS facade.
Do not worry if you do not fully understand this code, specially regarding the state functionality. That is just a bonus.
type Constructor<T = {}> = new (...args: any[]) => T;
interface ComposedApp {
components: RenderedComponentMap;
menuOpen: boolean;
toggleMenu(): void;
state$: rxjs.Observable<State<EntityExample>>;
resetState(): void;
updateState(state: State<EntityExample>): void;
}
interface State<E> extends NgrxEntityState<E> {
loaded: boolean;
loading: boolean;
}
interface NgrxEntityState<E> {
ids: string[] | number[];
entities: { [id: string | number]: E };
}
interface EntityExample {
id: string | number;
entity: {
data: any;
};
}
type RenderedComponentMap = Map<any, RenderingStrategy>;
type RenderingStrategy = 'eager' | 'lazy';
const eagerComponent: any = {};
const lazyComponent: any = {};
const components: RenderedComponentMap = new Map([
[eagerComponent, 'eager'],
[lazyComponent, 'lazy'],
]);
const initialState: State<EntityExample> = {
ids: [1],
entities: {
[1]: {
id: 1,
entity: {
data: {},
},
},
},
loaded: false,
loading: false,
};
function shareReplayForMulticast<T>(): rxjs.MonoTypeOperatorFunction<T> {
return rxjs.shareReplay({ bufferSize: 1, refCount: true });
}
function RenderingDecorator<B extends Constructor>(
renderingApp: Pick<ComposedApp, 'components'>
): (Base: B, context: ClassDecoratorContext) => B {
return (Base: B, context: ClassDecoratorContext) => {
return context.kind === 'class'
? class extends Base {
components: RenderedComponentMap = renderingApp.components;
}
: Base;
};
}
function StateDecorator<B extends Constructor>(
initialState: State<EntityExample>
): (Base: B, context: ClassDecoratorContext) => B {
return (Base: B, context: ClassDecoratorContext) => {
return context.kind === 'class'
? class extends Base {
#dispatch$: rxjs.BehaviorSubject<State<EntityExample>>;
#initialState: State<EntityExample> = initialState;
state$: rxjs.Observable<State<EntityExample>>;
constructor(...args: any[]) {
super(...args);
this.#dispatch$ = new rxjs.BehaviorSubject<State<EntityExample>>(
this.#initialState
);
this.state$ = this.#dispatch$
.asObservable()
.pipe(shareReplayForMulticast());
}
resetState(): void {
this.#dispatch$.next(this.#initialState);
}
updateState(state: State<EntityExample>): void {
this.#dispatch$.next(state);
}
}
: Base;
};
}
function MenuDecorator<B extends Constructor>(): (
Base: B,
context: ClassDecoratorContext
) => B {
return (Base: B, context: ClassDecoratorContext) => {
return context.kind === 'class'
? class extends Base {
menuOpen: boolean = false;
toggleMenu(): void {
this.menuOpen = !this.menuOpen;
}
}
: Base;
};
}
interface AppComponent extends ComposedApp {}
@RenderingDecorator({
components,
})
@StateDecorator(initialState)
@MenuDecorator()
class AppComponent {
property = 'property';
method(): string {
return 'method';
}
}
const component = new AppComponent();
console.log('AppComponent property:::', component.property);
console.log('AppComponent method():::', component.method());
console.log(
'AppComponent components.get(eagerComponent) <---:::',
component.components.get(eagerComponent)
);
console.log(
'AppComponent components.get(lazyComponent) <---:::',
component.components.get(lazyComponent)
);
component.state$
.pipe(rxjs.take(1))
.subscribe((state: State<EntityExample>) =>
console.log('AppComponent state <---:::', state)
);
console.log(
'AppComponen menuOpen before toggleMenu() <---:::',
component.menuOpen
);
component.toggleMenu();
console.log(
'component menuOpen after toggleMenu() <---:::',
component.menuOpen
);
Our app component is growing. With the 3 combined decorators it should hold these extra members:
interface ComposedApp {
components: RenderedComponentMap;
menuOpen: boolean;
toggleMenu(): void;
state$: rxjs.Observable<State<EntityExample>>;
resetState(): void;
updateState(state: State<EntityExample>): void;
}
Decorating the app component this way:
interface AppComponent extends ComposedApp {}
@RenderingDecorator({
components,
})
@StateDecorator(initialState)
@MenuDecorator()
class AppComponent {
Notice that decorator expressions are evaluated top-to-bottom but the results are then called as functions bottom-to-top. In this case, the first code to be evaluated will belong to @RenderingDecorator
whereas the first code to be executed will belong to @MenuDecorator
This is because all this code "desugars" into something like this:
Component = RenderingDecorator(StateDecorator(MenuDecorator(AppComponent)));
That means that RenderingDecorator
's base is not AppComponent
but StateDecorator()
. It is important to realize this, since only the bottom decorator will have access to the decorated class and its prototype.
In this example it would not matter though. We would still get the desired behavior by accessing all members through the app component instance:
Composition with mixins
Mixin classes are a more traditional way to apply the mixin patterrn. They have reached some popularity in the JavaScript/TypeScript community, since they are a good choice for component scalability. You can find them in packages like Angular Material.
Let us take a look at the same code as before, but using mixins:
type Constructor<T = {}> = new (...args: any[]) => T;
interface RenderingApp {
components: RenderedComponentMap;
}
interface StateApp<S> {
state$: rxjs.Observable<S>;
resetState(): void;
updateState(state: S): void;
}
interface MenuApp {
menuOpen: boolean;
toggleMenu(): void;
}
interface State<E> extends NgrxEntityState<E> {
loaded: boolean;
loading: boolean;
}
interface NgrxEntityState<E> {
ids: string[] | number[];
entities: { [id: string | number]: E };
}
interface EntityExample {
id: string | number;
entity: {
data: any;
};
}
type RenderedComponentMap = Map<any, RenderingStrategy>;
type RenderingStrategy = 'eager' | 'lazy';
const emptyBase: Constructor = class {};
const eagerComponent: any = {};
const lazyComponent: any = {};
const components: RenderedComponentMap = new Map([
[eagerComponent, 'eager'],
[lazyComponent, 'lazy'],
]);
const initialState: State<EntityExample> = {
ids: [1],
entities: {
[1]: {
id: 1,
entity: {
data: {},
},
},
},
loaded: false,
loading: false,
};
function shareReplayForMulticast<T>(): rxjs.MonoTypeOperatorFunction<T> {
return rxjs.shareReplay({ bufferSize: 1, refCount: true });
}
function RenderingMixin<B extends Constructor>(
Base: B,
renderingApp: RenderingApp
): Constructor<RenderingApp> & B {
return class extends Base {
components: RenderedComponentMap = renderingApp.components;
};
}
function StateMixin<B extends Constructor>(
Base: B,
initialState: State<EntityExample>
): Constructor<StateApp<State<EntityExample>>> & B {
return class extends Base {
#dispatch$: rxjs.BehaviorSubject<State<EntityExample>>;
#initialState: State<EntityExample> = initialState;
state$: rxjs.Observable<State<EntityExample>>;
constructor(...args: any[]) {
super(...args);
this.#dispatch$ = new rxjs.BehaviorSubject<State<EntityExample>>(
this.#initialState
);
this.state$ = this.#dispatch$
.asObservable()
.pipe(shareReplayForMulticast());
}
resetState(): void {
this.#dispatch$.next(this.#initialState);
}
updateState(state: State<EntityExample>): void {
this.#dispatch$.next(state);
}
};
}
function MenuMixin<B extends Constructor>(Base: B): Constructor<MenuApp> & B {
return class extends Base {
menuOpen: boolean = false;
toggleMenu(): void {
this.menuOpen = !this.menuOpen;
}
};
}
class AppComponent extends RenderingMixin(
StateMixin(MenuMixin(emptyBase), initialState),
{
components,
}
) {
property = 'property';
method(): string {
return 'method';
}
}
const component = new AppComponent();
console.log('AppComponent property:::', component.property);
console.log('AppComponent method():::', component.method());
console.log(
'AppComponent components.get(eagerComponent) <---:::',
component.components.get(eagerComponent)
);
console.log(
'AppComponent components.get(lazyComponent) <---:::',
component.components.get(lazyComponent)
);
component.state$
.pipe(rxjs.take(1))
.subscribe((state: State<EntityExample>) =>
console.log('AppComponent state <---:::', state)
);
console.log(
'AppComponen menuOpen before toggleMenu() <---:::',
component.menuOpen
);
component.toggleMenu();
console.log(
'component menuOpen after toggleMenu() <---:::',
component.menuOpen
);
We get the same output as before, however, there are some differences. The first thing we notice is that we have de-coupled our ComposedApp
interface into 3: RenderingApp
, StateApp
and MenuApp
. So now we do not need to add an interface to the decorated class but rather use those 3 interfaces to type the constructor returned by the mixin classes.
function RenderingMixin<B extends Constructor>(
Base: B,
renderingApp: RenderingApp
): Constructor<RenderingApp> & B {
return class extends Base {
components: RenderedComponentMap = renderingApp.components;
};
}
There are no factory functions here. We directly return the extended class. And we do not decorate the component class but rather extend it from a higher-order one:
const emptyBase: Constructor = class {};
[...]
class AppComponent extends RenderingMixin(
StateMixin(MenuMixin(emptyBase), initialState),
{
components,
}
) {}
The mixin logic is exactly the same as with decorators, but we need an emptyBase
constructor for the deepest mixin, to pass it as base.
Notice then that non of the mixins will have access to AppComponent
. Unlike with decorators, it is not possible to access to that class and its prototype.
Edge cases
Lit uses decorators to register web components and there is a reason for it. Check it out:
export const customElement =
(tagName: string): CustomElementDecorator =>
(
classOrTarget: CustomElementClass | Constructor<HTMLElement>,
context?: ClassDecoratorContext<Constructor<HTMLElement>>
) => {
if (context !== undefined) {
context.addInitializer(() => {
customElements.define(
tagName,
classOrTarget as CustomElementConstructor
);
});
} else {
customElements.define(tagName, classOrTarget as CustomElementConstructor);
}
};
We could do the same following the conventions of the previous code:
function CustomElementDecorator<B extends Constructor<HTMLElement>>(
tagName: string
): (Base: B, context: ClassDecoratorContext) => B {
return (Base: B, context: ClassDecoratorContext) => {
context.addInitializer(() => {
customElements.define(tagName, Base);
});
return Base;
};
}
And then adding it to our decorators example:
interface AppComponent extends ComposedApp {}
@CustomElementDecorator('custom-tag') // <---
@RenderingDecorator({
components,
})
@StateDecorator(initialState)
@MenuDecorator()
class AppComponent extends HTMLElement {}
This code would apparently work. We could even check that we have registered the <custom-tag>
element in the console:
But as you can see, the custom element would only take the functionality of the RenderingDecorator
. So what if we move this decorator to the bottom?
interface AppComponent extends ComposedApp {}
@RenderingDecorator({
components,
})
@StateDecorator(initialState)
@MenuDecorator()
@CustomElementDecorator('custom-tag') // <---
class AppComponent extends HTMLElement {}
We would get an error:
That is because the decorator is now getting evaluated last but executed first, before the composition is finished.
So how do we apply the initialization to the right class but also at the right execution time? Surely by combining mixins and a single decorator that uses the addInitializer()
function to register the custom element when the mixin composition is done.
@CustomElementDecorator('custom-tag')
class AppComponent extends RenderingMixin(
StateMixin(MenuMixin(HTMLElement), initialState),
{}
This would just work.
Conclusion
Mixins are explicitly designed for composition, whereas decorators are more inclined towards metaprogramming by annotating classes or class members.
On the other hand, both of them can add or fully replace class members, but only the decorators have the ability to access the base prototype, so only those are capable of partially modifying the functionality of a class method, which could be useful sometimes.
We should also keep in mind that frameworks may contain decorators that are not compiled by the TypeScript compiler (tsc
). For example, Angular's @Component
, which is compiled by the Angular compiler (ngc
).
So it would be probably best to combine both features by convenience. Use mixins for class compositions that involve either adding or replacing class members. However, do use decorators for edge cases that may involve initializing or modifying functionality.
You can find the whole example code on decorators and mixins shown in this article in this repository.
Happy coding!
Subscribe to my newsletter
Read articles from Andres Gesteira directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Andres Gesteira
Andres Gesteira
Meet Andrés Gesteira, a filmmaker turned into software developer by the whims of fate. Born in Madrid, he took a detour from his initial career to become a self-taught programmer. For over seven years, Andrés has been a relevant player in Berlin's tech scene, architecting diverse Angular projects for companies of all sizes and thriving on crafting immaculate, scalable code. Beyond coding, you'll find him running, swimming, reading or indulging in good food and wine.