DEV Community

Luke Harold Miles
Luke Harold Miles

Posted on

Use fake static classes to protect your namespace (js/ts)

We all know that modules let you hide a symbol in a file so other files don't collide with it.

And we're all enlightened post-OOP folks who do this

type Point = [x: number, y: number]
function magnitude(p: Point) {
    return Math.sqrt(p[0] * p[0] + p[1] * p[1])
}
Enter fullscreen mode Exit fullscreen mode

instead of doing this

class Point {
    constructor(public x: number, public y: number) {}
    magnitude() {
        return Math.sqrt(this.x * this.x + this.y * this.y)
    }
}
Enter fullscreen mode Exit fullscreen mode

The types & interfaces & functions approach prevents weird inheritance patterns, serializes well for database/filesystem/networking purposes, and typically lets you write more concise code because you don't have to tear apart and re-instantiate these classes all the time:

// nice
function add(p: Point, q: Point): Point {
    return [p[0] + q[0], p[1] + q[1]]
}
// annoying constructor; less symetric
class Point {
    // ...
    add(q) {
        return new Point(this.x + q.x, this.y + q.y)
    }
}
Enter fullscreen mode Exit fullscreen mode

But something has been lost in this transition. The project namespace becomes massive. In any large project, there are going to be some common operations that you want to do with different types of data. ECMAScript modules allow you to use a short, appropriate names. The project ends up looking like this:

// accounts.ts
function transfer(accountId: string, amount: number) {}

// item-management.ts
function transfer(from: User, to: User, itemId: string) {}

// array-helpers.ts
function transfer<T>(from: T[], to: T[], val: T) {}

// ...
Enter fullscreen mode Exit fullscreen mode

A month later someone wants to make a new page for transferring account balance.

  • They try jumping to transfer in the codebase and there are five exported definitions.
  • Auto-import suggests junk from unrelated parts of the project.
  • There is no easy way to find all the methods on User because there are tons of short functions scattered throughout the project.

Solution

Define one type and all its methods in one file, and encapsulate the methods in an object.

// Point.ts
export type Point = [x: number, y: number]
export const Point = {
    mag: (p: Point) => Math.sqrt(p[0] * p[0] + p[1] * p[1]),
    add: (p: Point, q: Point) => [p[0] + q[0], p[1] + q[1]],
    mul: (p: Point, factor: number) => [p[0] * factor, p[1] * factor],
} as const

// some-application-code.ts
import { Point } from './Point'

function whatever() {
    const p: Point = [5, 6]
    const q: Point = [10, 11]
    return Point.add(p, q)
}
Enter fullscreen mode Exit fullscreen mode

This will

  • stop the project vocabulary from becoming too large,
  • allow you to see all the methods functions available on a class instance value when using it,
  • give you a clear place to write code when you want to expand a type, and
  • avoid huge import statement blocks,
  • allow you to search in project for a specific function (Point.add is unique but add would have lots of false positives),
  • allow you to use shorter function names without ambiguity.

Discussion (4)

Collapse
supportic profile image
Supportic • Edited on

I don't get your point at all.
This

Point = {
    mag: (p: Point) => Math.sqrt(p[0] * p[0] + p[1] * p[1]),
    add: (p: Point, q: Point) => [p[0] + q[0], p[1] + q[1]],
    mul: (p: Point, factor: number) => [p[0] * factor, p[1] * factor],
} as const
Enter fullscreen mode Exit fullscreen mode

is basically this

class Point {
    // ...
    add(q) {
        return new Point(this.x + q.x, this.y + q.y)
    }
}
Enter fullscreen mode Exit fullscreen mode

What stands out to me in favour of classes is that you know exactly what you modify or are you able to tell me without reading the documentation what p[0] means?

The initial problem you mentioned regarding the polution and especially with this example:

function transfer(accountId: string, amount: number) {}
function transfer(from: User, to: User, itemId: string) {}
function transfer<T>(from: T[], to: T[], val: T) {}
Enter fullscreen mode Exit fullscreen mode

is naturally solved by classes. They do different things even if they are named the same. Otherwise you could throw in an object and check which properties are defined and act accordingly when the functionallity stays the same e.g.

function transfer(options) {
  options = options || {}
  const {accountid,amount, from, to, itemId} = options

  if(accountid && amount) ...
}
Enter fullscreen mode Exit fullscreen mode
Collapse
qpwo profile image
Luke Harold Miles Author

My biggest issue with classes these days is actually that you have to instantiate them to use methods after you get the value from the db. So I am a bit biased by my particular use case.

You could do all this with static methods but an object works just as well and is shorter and clearly cannot be instantiated.

Collapse
qpwo profile image
Luke Harold Miles Author

Languages like Go and Rust solve the namespace pollution problem with interfaces and traits respectively, but ts/js has no such feature unfortunately. Also check out clojure's a-la-cart dispatch.

Collapse
qpwo profile image
Luke Harold Miles Author

Another opinion I have is that property getting and method invocation should not look the same. So if typescript does one day add a receiver-function-like feature (unlikely) it should look like this:

const x = point.x
const mag = point->mag()
Enter fullscreen mode Exit fullscreen mode

But the syntax is huge as-is anyway.