Skip to content

Instantly share code, notes, and snippets.

@elektronik2k5
Created September 22, 2023 11:12
Show Gist options
  • Save elektronik2k5/55786d88067ccc56565b3787fa70d2e0 to your computer and use it in GitHub Desktop.
Save elektronik2k5/55786d88067ccc56565b3787fa70d2e0 to your computer and use it in GitHub Desktop.

Code style choices and rationale

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 of extends 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 static create factory method instead. This allows us to pass the create 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.

Composition, models and stores

We use composition for two purposes:

  1. To avoid implementing the same (or very similar) functionality in multiple places
  2. 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.

Classes and factory functions

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 }));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment