To`class` or not to `class`?
angular - Is there a type for "Class" in Typescript? And does "any" include it? - Stack Overflow
What is the difference between type and class in Typescript? - Stack Overflow
What's the best practice for when to use interface vs. class vs. type?
Watched this old YouTube classic recently: Brian Will's Object-Oriented Programming is Bad.
And was recently looking through this codebase https://github.com/tinyplex/tinybase and realized there is not a single class keyword used.
Interested in people's thoughts/drawbacks on no-classes vs classes in general, and perhaps using the TinyBase's codebase as a basis for evaluating/reviewing the no-classes approach on a real-world project. Would this codebase be easier to read/document/visualize if classes were used.
I also included a short example of the different approaches:
Function variable version
main.ts
const store: Store = createStore('foo')
store.foo()store.ts
import {createStore as createStoreDecl} from './store.d.ts'
// export function createStore(foo: string): Store { <- alternative form
export async const createStore: createStoreDecl = (foo: string): Store {
const bar = 1
await initStuff()
function printFoo() {
console.log(foo, bar)
}
return {
foo() { printFoo() },
}
}store.d.ts
/** Some docs */
export async function createStore(foo: string): Store;
export interface Store {
foo()
}Function version with exports
This version extracts a pure function and exports it.
export function printFoo(foo, bar) { console.log(foo, bar) }
export async function createStore(foo: string): Store {
const bar = 1
await initStuff()
return {
foo() { printFoo(foo, bar) },
}
}
// Potentially export private stuff in a private variable like so:
// export const private = {printFoo}Class version
main.ts
const store: Store = new Store('foo')
await store.init()
store.foo()store.ts
class Store {
#foo: string
static #bar = 1
constructor(foo: string) { this.foo = foo; }
async init() { await initStuff() }
/** Some docs */
foo() { this.#printFoo() }
#printFoo() {
const Clazz = <typeof Store>this.constructor
const bar = Clazz.bar
console.log(this.foo, bar)
}
}My thoughts on no-classes:
Pros
-
Async constructors are nice.
-
newkeyword is awkward and doesn't chain well. -
Documentation/typing in a separate file doesn't clutter code. This is also possible for classes though.
Cons
-
Typedef for function version must be separate to method definition. Can be positive and negative.
-
Commas in object definitions are not as clean.
-
Instance vars are not accessible ever. For example
const foocan never be accessed, compared to#privatein classes which I think you can do with reflection. There have been several times I wanted to read or monkey-patch one of these variables when desparately debugging some third-party library. -
Duplication of factory type definition when using
const foo = createFoo()form and its messy needed to assign type to the variable and the function. -
Classes make it easier to see all fields and methods and their visibility (private, static, etc.)
-
Functions declared in the factory body (i.e.
printFoo) can name-clash with factory params. -
Harder to test private factory-scope functions.
-
Inheritance is harder to document/visualize. In TinyBase they support custom
Persisters via createCustomPersister, which could neatly be implemented using inheritance which would also allow automatic documentation. Would be interested to know pro/cons of this approach.
On the function version with exported functions:
Pros
Testable
The function's data dependencies are explicit in the params. In the previous example, it's not clear until examining the entire method, which fields it depends on.
Easier to extract out generic/reusable utility functions, which helps keep the custom business logic clear. We want as many pure functions (i.e. not touching shared state) as possible.
Tends to be closer to what my code looks like on my first-attempt, before refactoring to allow configuration and additional features. This makes it easy to understand.
A lot of this can still be done with classes though - you can always just create a function instead of a static.
Cons
Cannot enforce private visibility.
Can be verbose and repetitive having to write the same arguments and parameters for each call and definition.
Developer may end up passing in a "context" object as a single param to avoid the previous issue which ends up defeating the purpose, and we are back to potentially depending on all the fields in a class.
The equivalent for what you're asking in typescript is the type { new(): Class }, for example:
class A {}
function create(ctor: { new(): A }): A {
return new ctor();
}
let a = create(A); // a is instanceof A
(code in playground)
The code above will allow only classes whose constructor has no argument. If you want any class, use new (...args: any[]) => Class
The simplest solution would be let variable: typeof Class.
Here an example:
class A {
public static attribute = "ABC";
}
function f(Param: typeof A) {
Param.attribute;
new Param();
}
f(A);
Typescript has two different universes that come into contact in some points: Value space and Type space. Type space is where types are defined and types get erased completely and don't exist at runtime. Value space contains values and will obviously exist at runtime.
What is a value? Value literals, variables, constants and parameters are obviously values. Functions and class declarations are also values as they do have a runtime object backing them up, namely the function object and the class constructor (also a function). Enums are also values as they are backed up by an object at runtime.
What is a type? Any definition with a type keyword is a type as well as interfaces, class declarations and enums
You will notice I mentioned class declarations in both spaces. Classes exist in both type space, and value space. This is why we can use them both in type annotations (let foo: ClassName) and in expressions (ex new ClassName()).
Enums also span both worlds, they also represent a type we can use in an annotation, but also the runtime object that will hold the enum.
Names in type space and value space don't collide, this is why we can define both a type and a variable with the same name:
type Foo = { type: true }
var Foo = { value : true } // No error, no relation to Foo just have the same name in value space
Class declarations and enums, since they span both spaces will 'use up' the name in both spaces and thus we can't define a variable or a type with the same name as a class declaration or enum (although we can do merging but that is a different concept)
In your specific case, Point is just a type, something we can use in type annotations, not something we can use in expressions that will need to have a runtime presence. In this case the type is useful as it allows the compiler to structurally check that the object literal is assignable to the Point type:
let p: Point = { x: 10, y: 15 }; // OK
let p: Point = { x: 10, y: 15, z: 10 }; // Error
If you want to create a class, you will need to do that with the class keyword, as that will create a runtime value that is not just a type:
class Point{
constructor(public x: number, public y: number){}
}
let p = new Point(10,10)
You use a type (or in other cases an interface) for type annotations to indicate the structure of JavaScript objects:
type Point = {
x: number, y: number
}
function doSomething(p: Point) {
}
let p: Point = { x: 10, y: 15 };
doSomething(p);
These type annotations are subject to structural typing, meaning that in this specific case you could drop the type annotation:
let p = { x: number, y: number };
doSomething(p);
A class is something entirely different, which provides you a more elegant alternative to JS prototype inheritance:
class Point {
public x: number;
public y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
public move(deltaX: number, deltaY: number) {
this.x = this.x + deltaX;
this.y = this.y + deltaY;
}
}
let p = new Point(10, 15);
p.move(1, 2);
When you look at the generated JS code, you will quickly notice the difference:
The type declaration is dropped in the generated code.
The class definition is turned into a JS function with prototype inheritance