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])
}
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)
}
}
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)
}
}
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) {}
// ...
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)
}
This will
- stop the project vocabulary from becoming too large,
- allow you to see all the
methodsfunctions available on aclass instancevalue 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 butadd
would have lots of false positives), - allow you to use shorter function names without ambiguity.
Top comments (4)
I don't get your point at all.
This
is basically this
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:
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.
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.
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.
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:
But the syntax is huge as-is anyway.