DEV Community

loading...

Tidy TypeScript: Avoid traditional OOP patterns

ddprrt profile image Stefan Baumgartner Originally published at fettblog.eu on ・5 min read

This is the third article in a series of articles where I want to highlight ways on how to keep your TypeScript code neat and tidy. This series is heavily opinionated and you might find out things you don't like. Don't take it personally, it's just an opinion.

This time we look at POOP, as in "Patterns of Object-Oriented Programming". With traditional OOP I mostly mean class-based OOP, which I assume the vast majority of developers think of when talking OOP. If you come from Java or C#, you might see a lot of familiar constructs in TypeScript, which might end up as false friends in the end.

Avoid static classes

One thing I see a lot from people who worked a lot with Java is their urge to wrap everything inside a class. In Java, you don't have any other options as classes are the only way to structure code. In JavaScript (and thus: TypeScript) there are plenty of other possibilities that do what you want without any extra steps. One of those things is static classes or classes with static methods, a true Java pattern.

// Environment.ts

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());
Enter fullscreen mode Exit fullscreen mode

While this works and is even -- sans type annotations -- valid JavaScript, it's way too much ceremony for something that can easily be just plain, boring functions:

// Environment.ts
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());
Enter fullscreen mode Exit fullscreen mode

The interface for your users is exactly the same. You can access module scope variables just the way you would access static properties in a class, but you have them module-scoped automatically. You decide what to export and what to make visible, not some TypeScript field modifiers. Also, you don't end up creating an Environment instance that doesn't do anything.

Even the implementation becomes easier. Check out the class version of variables():

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { 
    return this.variableList;
   }
}
Enter fullscreen mode Exit fullscreen mode

As opposed to the module version:

const variableList: string = []

export function variables(): string[] {
  return variableList;
}
Enter fullscreen mode Exit fullscreen mode

No this means less to think about. As an added benefit, your bundlers have an easier time doing tree-shaking, so you end up only with the things you actually use:

// Only the variables function and variablesList 
// end up in the bundle
import { variables } from "./Environment";

console.log(variables());
Enter fullscreen mode Exit fullscreen mode

That's why a proper module is always preferred to a class with static fields and methods. That's just an added boilerplate with no extra benefit.

Avoid namespaces

As with static classes, I see people with a Java or C# background clinging on to namespaces. Namespaces are a feature that TypeScript introduced to organize code long before ECMAScript modules were standardized. They allowed you to split things across files, merging them again with reference markers.

// file users/models.ts
namespace Users {
  export interface Person {
    name: string;
    age: number;
  }
}

// file users/controller.ts

/// <reference path="./models.ts" />
namespace Users {
  export function updateUser(p: Person) {
    // do the rest
  }
}
Enter fullscreen mode Exit fullscreen mode

Back then, TypeScript even had a bundling feature. It should still work to this day. But as said, this was before ECMAScript introduced modules. Now with modules, we have a way to organize and structure code that is compatible with the rest of the JavaScript ecosystem. So that's a plus.

So what do we need namespaces for?

Extending declarations

Namespaces are still valid if you want to extend definitions from a third party dependency, e.g. that lives inside node modules. Some of my articles use that heavily. For example if you want to extend the global JSX namespace and make sure img elements feature alt texts:

declare namespace JSX {
  interface IntrinsicElements {
    "img": HTMLAttributes & {
      alt: string,
      src: string,
      loading?: 'lazy' | 'eager' | 'auto';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Or if you want to write elaborate type definitions in ambient modules. But other than that? There is not much use for it anymore.

Needless namespaces

Namespaces wrap your definitions into an Object. Writing something like this:

export namespace Users {
  type User = {
    name: string;
    age: number;
  }

  export function createUser(name: string, age: number): User {
    return { name, age }
  }
}
Enter fullscreen mode Exit fullscreen mode

emits something very elaborate:

export var Users;
(function (Users) {
    function createUser(name, age) {
        return {
            name, age
        };
    }
    Users.createUser = createUser;
})(Users || (Users = {}));
Enter fullscreen mode Exit fullscreen mode

This not only adds cruft but also keeps your bundlers from tree-shaking properly! Also using them becomes a bit wordier:

import * as Users from "./users";

Users.Users.createUser("Stefan", "39");
Enter fullscreen mode Exit fullscreen mode

Dropping them makes things a lot easier. Stick to what JavaScript offers you. Not using namespaces outside of declaration files makes your code clear, simple, and tidy.

Avoid abstract classes

Abstract classes are a way to structure a more complex class hierarchy where you pre-define some behavior, but leave the actual implementation of some features to classes that extend from your abstract class.

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }

  abstract move(): string;
}

class Human extends Lifeform {
  move() {
    return "Walking, mostly..."
  }
}
Enter fullscreen mode Exit fullscreen mode

It's for all sub-classes of Lifeform to implement move. This is a concept that exists in basically every class-based programming language. The problem is, JavaScript isn't traditionally class-based. For example, an abstract class like below generates a valid JavaScript class, but is not allowed to be instantiated in TypeScript:

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

const lifeform = new Lifeform(20);
//               ^ 💥 Cannot create an instance of an abstract class.(2511)
Enter fullscreen mode Exit fullscreen mode

This can lead to some unwanted situations if you're writing regular JavaScript but rely on TypeScript to provide you the information in form of implicit documentation. E.g. if a function definition looks like this:

declare function moveLifeform(lifeform: Lifeform);
Enter fullscreen mode Exit fullscreen mode
  • You or your users might read this as an invitation to pass a Lifeform object to moveLifeform. Internally, it calls lifeform.move().
  • Lifeform can be instantiated in JavaScript, as it is a valid class
  • The method move does not exist in Lifeform, thus breaking your application!

This is due to a false sense of security. What you actually want is to put some pre-defined implementation in the prototype chain, and have a contract that definitely tells you what to expect:

interface Lifeform {
  move(): string
}

class BasicLifeForm {
  age: number;
  constructor(age: number) {
    this.age = age
  }
}

class Human extends BasicLifeForm implements Lifeform {
  move() {
    return "Walking"
  }
}
Enter fullscreen mode Exit fullscreen mode

The moment you look up Lifeform, you can see the interface and everything it expects, but you hardly run into a situation where you instantiate the wrong class by accident.

Bottom line

TypeScript included bespoke mechanisms in the early years of the language, where there was a severe lack of structuring in JavaScript. Now that JavaScript reached a different language of maturity, it gives you enough means to structure your code. So it's a really good idea to make use of what's native and idiomatic: Modules, objects, and functions. Occasional classes.

Discussion (0)

pic
Editor guide