We use classes due to their superior static analysis capabilities in TypeScript. However, classes come with well known pitfalls which we want to mitigate by:
-
Avoiding inheritance and using composition. Use TypeScript's
implements
instead ofextends
when declaring classes:- class MyArray extends Array {} + class MyArray implements Array {}
More on composition later.
-
Avoiding the perils of
this
in JS and not caring who calls methods or where from. Use only class initializers with arrow functions as values, which lets us pass methods around freely as event handlers or callbacks:class Foo { - static classMethod() { this /* is set by the caller */ } + static classMethod = () => { this /* is always Foo */ } - method() { this /* is set by the caller */ } + method = () => { this /* is always an instance of Foo */} }
-
Avoiding the
new
operator and use a staticcreate
factory method instead. This allows us to pass thecreate
method as a callback:class Task { constructor() {} static create = (...args) => new this(...args); } const tasksData = [{}, {}, {}];
- const tasks = tasksData.map(taskData => new Task(taskData)) + const tasks = tasksData.map(Task.create)
In addition, avoiding
new
allows us to change the implementation to return different classes/objects/whatever - without modifying calling code.
These rules are enforced using eslint
custom rules.
We use composition for two purposes:
- To avoid implementing the same (or very similar) functionality in multiple places
- To break down big and complex features into smaller and more manageable parts
In the next example, FooModel
and BarModel
are self contained domain entities. They aren't coupled to each other or their calling code in any way. We can instantiate them in different places and have tests for each. We name them "Models" because they model and encapsulate specific functionality or aspect of a given problem domain. In other words, they represent and contain a specific feature.
class FooModel {}
interface WithFooModel {
fooModel: FooModel;
}
class BarModel {}
interface WithBarModel {
barModel: BarModel;
}
class FooAndBarModel implements WithFooModel, WithBarModel {
fooModel: FooModel;
barModel: BarModel;
}
FooAndBarModel
uses these entities and includes glue code to wire them together in order implement a bigger feature with logic which requires the use of both FooModel
and BarModel
.
Purely for convenience, we call big models, which are the top level entities in our app "Store"s. Stores usually represent entire application pages or domain objects which a lot of responsibility. No other entity composes them and they are the heart of our application logic and where all the high level glue code lives.
Factory functions allow us to implement lazy initialization and singletons, along with default arguments - without compromising on type safety or strictness:
// Properties are all mandatory
interface TaskModelProps {
id: number;
text: string;
isCompleted: boolean;
}
class TaskModel implements TaskModelProps {
id: number;
text: string;
isCompleted: boolean;
// Properties are all mandatory and validated by the compiler
constructor({ id, text, isCompleted }: TaskModelProps) {
this.id = id;
this.text = text;
this.isCompleted = isCompleted;
}
// Factory method arguments are inferred from the constructor
static create = (...args: ConstructorParameters<typeof this>) => new this(...args);
}
type MaybeTaskModel = TaskModel | null;
// Lazily assigned singleton instance
let maybeTask: MaybeTaskModel = null;
// Factory function which returns the singleton instance or creates and returns it when first called + default arguments
export function getOrCreateTask({ id = 0, text = '', isCompleted = false }: TaskModelProps): TaskModel {
if (maybeTask) {
return maybeTask;
}
return (maybeTask = TaskModel.create({ id, text, isCompleted }));
}