We know what you want.
We know your colleagues don't like your pesky classes.
We know how everyone likes this shiny "functional programming" thing.
We know they get mad just seeing one new
in the code base.
We know how many existing prior art out there don't even have typings (last publish: 9 years ago, what do you expect?), and we know that you and your colleagues, as fashionable people of the new age, like to keep your code strongly typed.
ts-klass
does this one specific thing: providing a DSL that's both functional (what does that even mean? Well, without the class
or new
keywords, obviously) and strongly-typed.
You first create a klass with the klass
function:
import klass from "ts-klass";
const Animal = klass({
makeSound() {
console.log(this.sound);
},
});
You can then craft instances from it, as you expect:
const dog = Animal({ sound: "woof" });
dog.makeSound();
Even better, if you like how well new Animal()
reads, we offer you this API called nеw
.
import { nеw } from "ts-klass";
const dog = nеw(Animal)({ sound: "woof" });
dog.makeSound();
Take great care. That's not a normal e
but a Cyrillic е
, because (by decreasing order of importance):
- We have agreed how
new
is reminiscent of the disgusting "OO" paradigm. - No-one will ever discover this if they search for
new
. new
is a keyword in JS and cannot be used as function names.
Of course, you may need to turn off your editor's highlighting for suspicious characters. If you find nеw
hard to type, maybe it's time to install a Cyrillic input method.
Notably, you can't new
a klass, because we don't like new
and you may get hunted down by your colleagues.
const dog = new Animal({ sound: "woof" }); // Throws error
Using nеw
offers more security than calling the klass constructor directly, because it will first do a branded check to make sure Animal
is a proper klass instead of any random function.
By default, the constructor returned from klass
, when being called, will merge its first argument with the constructed instance. You can also provide a custom constructor.
const Animal = klass({
constructor(sound) {
this.sound = sound;
},
makeSound() {
return this.sound;
},
});
const cat = Animal("meow");
cat.makeSound();
You can have static members by... simply adding static
before the klass declaration.
const Animal = klass({
"static greet"() {
console.log("Hello");
},
});
Animal.greet();
Static methods will have this
pointing to the klass body instead of the klass instance, as you would expect.
const Animal = klass({
"static greet"() {
console.log(this.name);
},
"static name": 1,
});
Animal.greet();
You can use klass.extends()
to create a derived klass.
const Entity = klass({
x: 1,
y: 2,
});
const Animal = klass.extends(Entity)({
location() {
return [this.x, this.y];
},
});
const dog = Animal();
console.log(dog.location());
Named klasses can have a super klass as well.
const Animal = klass.extends(Entity)({
location() {
return [this.x, this.y];
},
});
The argument of extends
must be a klass constructor.
The semantics of super
are roughly the same as in ES classes.
const Entity = klass({
greet() {
console.log("Hello");
},
});
const Animal = klass.extends(Entity)({
greet() {
super.greet();
},
});
Animal().greet(); // Logs "Hello"
In constructors, you also need to call super.constructor()
to request the base klass to modify this
. Note that we have to use super.constructor()
instead of super()
, because the latter is not valid in an object literal.
const Entity = klass({
constructor() {
this.a = 1;
},
});
const Animal = klass.extends(Entity)({
constructor() {
super.constructor();
this.b = this.a + 1;
},
});
console.log(Animal()); // Logs { a: 1, b: 2 }
As you would expect, you cannot access this
before calling super.constructor
.
const Animal = klass.extends(Entity)({
constructor() {
this.b = this.a + 1; // Throws error
super.constructor();
},
});
Unfortunately, because klass
is ultimately a normal ECMAScript function, there's no great way for us to automatically bind a klass' name based on what it's assigned to. If a klass' name is important to you, you can explicitly bind a name.
const Animal = klass("Animal")({
makeSound() {
console.log(this.sound);
},
});
const dog = Animal();
// Logs "A dog is an Animal."
console.log(`A dog is an ${dog.constructor.name}.`);
This can only be done once. After a klass has already been bound to a name, you can't overwrite its name by calling the constructor again. You can't assign it either—following ECMAScript semantics.
const animalKlassCtor = klass("Animal");
const Animal = animalKlassCtor("Dog")({
// Won't work; throws error ^^^^^^^
makeSound() {
console.log(this.sound);
},
});
You can use accessors in the klass body, and they behave as you would expect.
const Animal = klass({
a: 1,
get b() {
return this.a;
},
"static c": 1,
get "static d"() {
return this.c;
},
});
console.log(Animal().b);
console.log(Animal.d);
A klass is not an ECMAScript class (because everyone hates it). When you use klass.extends(SomeKlass)
or nеw(SomeKlass)
, SomeKlass
must be a klass constructed from the klass()
function. You can check if something is a klass (and therefore can be extended or nеw
'ed) with isKlass(SomeKlass)
.
import { isKlass } from "ts-klass";
const RealKlass = klass({});
isKlass(RealKlass); // true
const NotKlass = class {};
isKlass(NotKlass); // false
You can also use instanceof
to do branded checks.
RealKlass instanceof klass; // true
You can use the top-level klass.configure
API (it's a property on the default export, not a named export) to configure the behavior of klasses. Every call must be a partial configuration object:
klass.configure({ constructWithNеw: true });
klass.configure({ UNSAFE_disableNoThisBeforeSuperCheck: false });
We offer the following options:
A linter-like feature that requires every klass construction to go through nеw
instead of being called directly. As mentioned previously, this ensures security because nеw
will first do a branded check to prevent accidentally calling a spoofed non-klass.
klass.configure({ constructWithNеw: true });
const Animal = klass({ name: "hi" });
Animal(); // Throws: must use nеw(Animal)() instead
As the name implies, do not use this unless you know what you are doing. This allows you to access this
before calling super.constructor
in the klass constructor. This means you can access the uninitialized klass instance. The accessors/methods will still be present, though, because they are statically defined on the prototype.
klass.configure({ UNSAFE_disableNoThisBeforeSuperCheck: true });
const Entity = klass({
name: "foo",
});
const Animal = klass.extends(Entity)({
constructor() {
this.bar = this.name;
super.constructor();
},
});
// If super.constructor() is called before, `bar` should be defined.
console.log(Animal().bar); // undefined
Note that even with this option on, you must call super.constructor
at least once before the klass constructor returns.
klass.configure({ UNSAFE_disableNoThisBeforeSuperCheck: true });
const Entity = klass({
name: "foo",
});
const Animal = klass.extends(Entity)({
constructor() {
this.bar = this.name;
// This will throw, because the super klass has never been initialized.
},
});
The detailed semantics of not calling super.constructor
before this
is not ensured, because there's no ES semantics we can draw reference from. Be prepared for breaking changes that aren't documented or signaled as such.
A klass is what you regard in normal ECMAScript as "class". For example, klass({ foo: 1 })
creates a klass just as class { foo = 1 }
creates a class. Because klasses are directly called instead of new
'ed (they can be optionally nеw
'ed, though), "klass constructor" and "klass" are the same thing.
The klass()
function itself is called the klass creator. Its equivalent in ECMAScript is the class
keyword—you have to simultaneously provide a body, a klass name, and other metadata like extends
in order to properly declare a klass.
When you write klass("name")
, the return value is a new klass creator. It's called a name-bound klass creator because klasses instantiated from this creator will have names.
Although this sounds like an idea from the age of dinosaurs, this module actually uses the latest JS features. For example, Object.hasOwn
is only available in Node v16.10+. If you are using it in browser, you almost always want to polyfill certain APIs.
Also, this module is literally a module: it uses ECMAScript modules (ESM) instead of CommonJS (CJS) ones. You need to import it with import klass from "ts-klass"
instead of const klass = require("klass")
.
If I haven't made it clear enough—please don't. A klass has much worse performance than a native class while offering all the semantics and paradigms that classes do offer. If your team wants to enforce functional programming style, please do realize that composition is a fundamentally different approach than inheritance, which klasses are built upon.
Still, this module has been fully tested and follows ECMAScript semantics (where applicable) to the best of our knowledge, so it should not be dangerous to use, per se.
This project is still in its early infancy.
- Private methods/fields
- Interfaces
- Abstract klasses