The M.I.N.T principal: a new guideline for when to use Object-Oriented programming in TypeScript

Afzal ImdadAfzal Imdad
8 min read

Picture says it all

Please note that classes are not synonymous with the term object-oriented (OO). OO is not a specific language feature but a set of principles to follow (Encapsulation, Abstraction, Inheritance, and Polymorphism). There are theoretically other ways to achieve OO besides classes, but in the modern JavaScript world, classes are how we most commonly do OO so for this article, I’ll sometimes use the two terms interchangeably.

Background

So I’m coming up on 10 years of experience as a JavaScript developer right now and like many, I studied computer-science and was indoctrinated into the “strict object-oriented” mindset. When I first started JavaScript I wrapped literally everything in classes which came with a whole host of caveats and led to a lot of boilerplate code.

Then I started learning react which dropped support for classes, and I also noticed that a bunch of cool new programming languages like Go, Rust, Zig were not using the class keyword anymore (without looking further and noticing that they were basically still doing OO but with the struct keyword instead). So then I tried to completely purge OO code from all my projects and even wrote medium articles about why I don’t use OO programming anymore.

Recently, I worked on a front-end chart making tool which involved a lot of hierarchical data. At first I tried place all my helper functions in the react component. I was calling the same functions on the same array of data which required that I continually pass the array every time. This bloated my react component and made the data-array hard to keep track of. Then I moved the data (and all the functions to manipulate it) into a class and this removed a lot boilerplate code and led to better separate of concerns. I finally encountered a real world scenario where OO made sense.

I’ve become more more nuanced in my opinion of different programming paradigms now and learned that, just like every other tool in this world, different programming-paradigms are better for different scenarios. For OO, I believe the magic rule is this: use it whenever you have “multiple instances of non-serializable data that is tightly-coupled with functions”. I was thinking we could call this the M.I.N.T. principle which stands for “Multiple-instances, Not-Serialized, and Tightly-Coupled”.

Examples of when to use OO

Data-Structures
A good of example of when to use OO is data-structures (i.e. JavaScript’s Map class). The two core features of OO (encapsulation and inheritance) are useful because it allows us to keep our internal data private and bundled with our functions and avoid repeat logic through inheritance. We could imitate the behavior of classes using wrapper functions (also known as the “closure factory pattern”) but then we’d have to do inheritance by merging object-literals together leading to potential collisions (functions/properties overwriting each other).

// JavaScript's built-in "Map" class
const usersMap = new Map<number, IUser>("data");

// Imitating a class using a wrapper function
const usersCustomMap = initCustomMap<number, IUser>("data"); 

function initCustomMap<T, U>(data): ICustomMap<T, U> {
  const data = deepClone(data);

  function get(key: T): U {
    ...do stuff
  }

  function set(key: T, value: T): void {
    ...do stuff
  }

  return {
    ...someParentDataStructure, // Inheritence by merging
    get,
    set,
  }
}

Libraries (sometimes) If a library export is just one function or a very simple object, using a wrapper function could be just fine. But if your library is a an object with multiple functions, especially if those functions require some settings for initialization, then it’s probably a good idea to initialize that object with a class.

const logger = new Logger(...your settings...);

logger.error("foo");
logger.warning("bar");

// Your logging library
class Logger {
  private printMode: "file" | "console";
  private format: "line" | "json";

  public constructor(printMode: "file" | "console", format: "line" | "json") {
    this.printMode = printMode;
    this.format = format;
  }

  public error(content: unknown): void {
    ...do stuff
  }

  public warning(content: unknown): void {
    ...do stuff
  }
}

Examples of when NOT to use OO

Input/Output data Have you ever written an API to handle some data that you’ve noticed that if you try to use a class to represent that data item you have to pass it through a constructor every time? That’s because when we move JavaScript objects through IO calls we’re just converting the key/values pairs to strings (or buffers, whatever the tool uses) and moving those. The functions aren’t moved and more importantly the instanceof operator will break because that IO data-item will not be an instance of the class until the new operator is used. Let’s look at a real world example-problem of this:

TypeORM is a very popular library for doing database queries in NodeJS. It uses classes to represent items in the database. If these data items only existed on the back-end/database this really wouldn’t be an issue, BUT what if we have a front-end too that’s also sending us user objects.

// **** User.ts **** //

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

interface IUser {
  id: number;
  name: string;
}

@Entity()
class User extends IUser {
    @PrimaryGeneratedColumn()
    public id: number

    @Column()
    public firstName: string

    public toString(): string {
       ...do stuff
    }

    public static Validate(arg: unknown): arg is IUser {
      ...do stuff
    }
}

// **** API layer **** //

import express, { Request, Response } from "express";
import UserService from "services/UserService";
import User from "entities/User";

const app = express();
app.put("/user", updateUser);

function addUser(req: Request, res: Response): void {
  const { user } = req.body;
  if (User.Validate(user)) {
    User.addUser(new User(user));
  }
}

// **** Service layer (UserService.ts) **** //

function addUsers(user: IUser): void {
  // Next line will be a problem unless we made sure to do 
  // "new User(req.body.user)", instanceOf would not work either
  console.log(user.toString());
}

The snippet we just looked at may not seem like a big deal, but what if we have lots of APIs that return hundreds of user objects. We’d have to constantly make sure we’re calling the constructor for every single one or use some library like NestJS which handles that for us. A much better solution is to decouple the data from functions when dealing with IO data. Here’s a better way to do the previous example using modular-object scripts:

// *** User.ts **** //

interface IUser {
  id: number;
  name: string;
}

function new_(): IUser {
  return { id: -1, name: "" }; 
}

function test(arg: unknown): arg is IUser {
  return ...do stuff
}

function toString(user: IUser): string {
  return ...do stuff
}

export default { new: new_, test } as const;

// **** API layer **** //

import express, { Request, Response } from "express";
import User from "@src/models/User";
import UserService from "@src/services/UserService";

const app = express();
app.put("/user", updateUser);

function updateUser(req: Request, res: Response): void {
  const { user } = req.body;
  // Next line will be a problem unless we do "new User(req.body.user)"
  if (User.test(user)) {
    UserService.addUser(user)
  }
}

// **** Service layer (UserService.ts) **** //

function addUsers(user: IUser): void {
   console.log(User.toString(user));
}

As you can see in the snippet we just did, we worked with the IUser type in many different places (including some IO calls) and nowhere did we need to worry about when the type was an instance of any class; we left it as a basic-object (inherits only from the Object class).

Note for React/Redux developers: have you ever used Redux and noticed that you can’t use classes like Map and Set for redux state properties. That’s because the redux state is serialized too. So there’s another example of why classes aren’t always the best when working with IO data.

Organizing layers of the application which are only initialized once JavaScript/TypeScript developers coming from a strict OO background often feel tempted to wrap every single function they create in a class. I’ll confess that I not only did it too, but I even went so far as writing this library to wrap all my APIs in classes when setting up an express server.

Using OO when we don’t need multiple instances though is not practical because we either need go through the hassle or marking every single function public static or calling the constructor in the export default statement. Going further, there are even dependency-injection libraries for JavaScript that instantiate the class for us but often require an additional abstraction layer via decorators. If you have a large number of functions that need to be grouped together just use a modular-object script and all the aforementioned problems are removed. If you need to initialize some variables for these functions (like what a constructor would do) just place them above the functions in the Run/Setup region of your script.

// **** UserService.ts using a class **** //

class UserService {
  private db: SomeDbLib;

  constructor() {
    this.db = new SomeDBLib("your settings");
  }

  public findById(id: number): Promise<User> {
    return this.db.tables("users").where({ id });
  }

  public async update(user: User): Promise<void> {
    await this.db.update(user).where({ id: user.id });
  }
}

export default new UserService(); // Have to do this to access the functions

// **** UserService using modular-object script **** //

// Setup Region
const db = new SomeDBLib("your settings");

// Functions Region
function findById(id: number): Promise<User> {
  return db.tables("users").where({ id });
}

function async update(user: User): Promise<void> {
  await db.update(user).where({ id: user.id });
}

export default { findById, update } as const;

Conclusion

In summary different programming paradigms, like languages themselves, are just tools and some work better for different situations. Use object-literals when procedural-programming makes the most sense and use classes when object-oriented programming makes the most sense. As a full-stack TypeScript web developer, the backbone of my applications is almost always procedural-programming, and I rarely implement new classes. Nor do I don’t agree with languages like Java that force you to do OO everywhere. Hopefully the MINT principle can help you forward by knowing the niche cases where OO makes sense. If you agree with this in principle but don’t like the acronym maybe you can suggest a better one?

0
Subscribe to my newsletter

Read articles from Afzal Imdad directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Afzal Imdad
Afzal Imdad