Skip to content

Commit

Permalink
refactor: normalize naming and terminology
Browse files Browse the repository at this point in the history
  • Loading branch information
Josh-Cena committed Apr 12, 2022
1 parent eb8ab2c commit 3a6b7dd
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 72 deletions.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Notably, you can't `new` a klass, because we don't like `new` and you may get hu
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](#branded-check) to make sure `Animal` is a proper klass instead of any random function.

### Explicit constructors

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.
Expand Down Expand Up @@ -129,15 +131,25 @@ const Animal = animalKlassCtor("Dog")({

### Branded check

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 `klass.isKlass(someKlass)`.
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)`.

```js
import { isKlass } from "ts-klass";

const RealKlass = klass({});
klass.isKlass(RealKlass); // true
isKlass(RealKlass); // true
const NotKlass = class {};
klass.isKlass(NotKlass); // false
isKlass(NotKlass); // false
```

## Terminology

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 class 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.

## FAQ

### Why does using this module result in a runtime error?
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.18.0",
"eslint": "^8.13.0",
"eslint-config-jc": "^2.1.2",
"eslint-config-jc": "^2.1.3",
"eslint-plugin-import": "^2.26.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
Expand Down
133 changes: 82 additions & 51 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,66 @@
export default function klass(bodyOrName) {
if (typeof bodyOrName === "string") {
return function klassWithName(body) {
// eslint-disable-next-line func-style
const nameBoundKlassCreator = function nameBoundKlassCreator(body) {
if (typeof body === "string") {
throw new Error(
`The klass already has a name bound as "${bodyOrName}". You can't re-write its name.`,
`The klass creator already has a name bound as "${bodyOrName}". You can't re-write its name.`,
);
}
const aNewKlass = klass(body);
Object.defineProperty(aNewKlass, "name", {
const NewKlass = klass(body);
Object.defineProperty(NewKlass, "name", {
value: bodyOrName,
writable: false,
enumerable: false,
configurable: true,
enumerable: false,
writable: false,
});
return aNewKlass;
return NewKlass;
};
Object.defineProperty(nameBoundKlassCreator, "boundName", {
value: bodyOrName,
configurable: false,
enumerable: true,
writable: false,
});
// nameBoundKlassCreator.extends = extend;
return nameBoundKlassCreator;
}
if (typeof bodyOrName !== "object" || !bodyOrName)
throw new Error("You can't create a klass with a non-object body.");
const body = bodyOrName;
const { constructor, ...methods } = body;
// Ignore existing prototype chain on body
Object.setPrototypeOf(body, Object.prototype);
const constructor = Object.hasOwn(body, "constructor")
? (() => {
const constructor = body.constructor;
delete body.constructor;
return constructor;
})()
: function defaultConstructor(props) {
if (!props) return;
Object.defineProperties(
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this,
Object.fromEntries(
Object.keys(props).map((k) => [
k,
{
value: props[k],
configurable: true,
enumerable: true,
writable: true,
},
]),
),
);
};

const [staticFields] = Object.entries(methods).reduce(
const [staticFields] = Object.entries(body).reduce(
(acc, [key, value]) => {
const trimmedKey = key.trim();
if (trimmedKey.startsWith("static ")) {
acc[0].push([trimmedKey.replace(/^static /, "").trim(), value]);
delete methods[key];
delete body[key];
} else {
acc[1].push([key, value]);
}
Expand All @@ -33,70 +69,65 @@ export default function klass(bodyOrName) {
[[], []],
);

Object.defineProperty(methods, "constructor", {
value: newKlass,
writable: true,
enumerable: false,
configurable: true,
});

function newKlass(...args) {
function SomeKlass(...args) {
if (new.target) {
throw new Error(
'Please don\'t new a klass, because we hate new. Call it directly or use the "nеw" API.',
);
}
const instance = Object.create(methods);
if (!Object.hasOwn(body, "constructor")) {
const props = args[0];
if (props) {
Object.defineProperties(
instance,
Object.fromEntries(
Object.keys(props).map((k) => [
k,
{
enumerable: true,
configurable: true,
writable: true,
value: props[k],
},
]),
),
);
}
} else {
constructor.call(instance, ...args);
}
const instance = Object.create(body);
constructor.call(instance, ...args);
return instance;
}
// Static fields are defined on the constructor
staticFields.forEach(([key, value]) => {
newKlass[key] = value;
SomeKlass[key] = value;
});
Object.defineProperty(newKlass, "name", {
// Base name; may be overridden later if the klass creator is called inside a
// name-bound one
Object.defineProperty(SomeKlass, "name", {
value: "",
writable: false,
configurable: true,
enumerable: false,
writable: false,
});
// Reflection: instance.__proto__.constructor
Object.defineProperty(body, "constructor", {
value: SomeKlass,
configurable: true,
enumerable: false,
writable: true,
});
newKlass[klassMarker] = true;
return newKlass;
// Brand for the newly created klass
SomeKlass[klassMarker] = true;
return SomeKlass;
}

// function extend(someKlass) {
// if (!klass.isKlass(someKlass))
// function extend(SuperKlass) {
// if (!isKlass(SuperKlass))
// throw new Error("You can only extend a klass.");
// return function subKlass(bodyOrName) {

// // eslint-disable-next-line @typescript-eslint/no-this-alias, @typescript-eslint/no-invalid-this
// const klassCreator = this;
// function derivedKlassCreator(body) {
// // eslint-disable-next-line @typescript-eslint/no-invalid-this
// return function extendedKlass(...args) {
// const instance = NewKlass(...args);
// };
// }
// if (klassCreator.boundName)
// derivedKlassCreator.boundName = klassCreator.boundName;
// derivedKlassCreator.baseKlass = SuperKlass;
// return derivedKlassCreator;
// }

klass.isKlass = (maybeKlass) => Boolean(maybeKlass[klassMarker]);
// klass.extends = extend;

const klassMarker = Symbol("klass");

export const isKlass = (maybeKlass) => Boolean(maybeKlass[klassMarker]);

export function nеw(someKlass) {
if (!klass.isKlass(someKlass))
if (!isKlass(someKlass))
throw new Error("nеw should only be called on klasses");
return someKlass;
}
79 changes: 67 additions & 12 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import klass, { nеw } from "../src/index.js";
import klass, { nеw, isKlass } from "../src/index.js";

describe("klass constructor", () => {
it("generates a newable object", () => {
it("can be directly called to construct instances", () => {
const Animal = klass({
makeSound() {
return this.sound;
Expand All @@ -11,7 +11,7 @@ describe("klass constructor", () => {
expect(cat.makeSound()).toBe("meow");
});

it("generates a klass without any enumerable keys by default, and resembles normal classes", () => {
it("has no enumerable keys by default, and resembles normal classes", () => {
const Animal = klass({ a: 1 });
expect(Object.keys(Animal)).toEqual([]);
expect(Object.getOwnPropertyNames(Animal)).toEqual(
Expand All @@ -26,6 +26,20 @@ describe("klass constructor", () => {
);
});

it("throws if trying to create a klass with a primitive as body", () => {
expect(() => klass(1)).toThrowErrorMatchingInlineSnapshot(
`"You can't create a klass with a non-object body."`,
);
expect(() => klass(null)).toThrowErrorMatchingInlineSnapshot(
`"You can't create a klass with a non-object body."`,
);
expect(() =>
klass(() => console.log("foo")),
).toThrowErrorMatchingInlineSnapshot(
`"You can't create a klass with a non-object body."`,
);
});

it("accepts an explicit constructor", () => {
const Animal = klass({
constructor(sound, name) {
Expand All @@ -40,6 +54,18 @@ describe("klass constructor", () => {
expect(cat.makeSound()).toBe("meow");
expect(cat.name).toBe("Fiona");
});

it("ignores existing prototypes of body", () => {
class RealClass {
a = 1;
}
const KlassClone = klass(new RealClass());
const instance = KlassClone();
expect(instance.a).toBe(1);
expect(Object.getPrototypeOf(Object.getPrototypeOf(instance))).toBe(
Object.prototype,
);
});
});

describe("static field", () => {
Expand Down Expand Up @@ -94,19 +120,23 @@ describe("static field", () => {

describe("name", () => {
it("allows binding an explicit name", () => {
const Animal = klass("Animal")({});
const animalKlassCreator = klass("Animal");
const Animal = animalKlassCreator({});
const dog = Animal();
expect(klass.isKlass(Animal)).toBe(true);
expect(animalKlassCreator.boundName).toBe("Animal");
expect(isKlass(Animal)).toBe(true);
expect(Animal.name).toBe("Animal");
expect(dog.name).toBe(undefined);
});

it("falls back to empty string", () => {
const Animal = klass({});
expect(Animal.name).toBe("");
});

it("is forbidden to be re-bound", () => {
expect(() => klass("foo")("bar")({})).toThrowErrorMatchingInlineSnapshot(
`"The klass already has a name bound as \\"foo\\". You can't re-write its name."`,
`"The klass creator already has a name bound as \\"foo\\". You can't re-write its name."`,
);
});
});
Expand All @@ -121,22 +151,46 @@ describe("name", () => {
// return [this.position, this.position];
// }
// });
// expect(Animal.location()).toEqual([1, 1]);
// expect(Animal().location()).toEqual([1, 1]);
// });
// it("can extend a named klass ctor", () => {
// const Entity = klass("Entity")({
// position: 1,
// });
// const Animal = klass("Animal").extends(Entity)({
// location() {
// return [this.position, this.position];
// }
// });
// expect(Animal().location()).toEqual([1, 1]);
// expect(Animal.name).toBe("Animal");
// });
// it("does not take the name from super klass", () => {
// const Entity = klass("Entity")({
// position: 1,
// });
// const Animal = klass.extends(Entity)({
// location() {
// return [this.position, this.position];
// }
// });
// expect(Animal.name).toBe("");
// });
// });

describe("isKlass", () => {
it("rejects non-klasses", () => {
expect(klass.isKlass(class {})).toBe(false);
expect(klass.isKlass({})).toBe(false);
expect(isKlass(class {})).toBe(false);
expect(isKlass({})).toBe(false);
const Foo = klass({});
expect(klass.isKlass(Foo())).toBe(false);
expect(isKlass(Foo())).toBe(false);
});

it("accepts klasses", () => {
const Foo = klass({});
expect(klass.isKlass(Foo)).toBe(true);
expect(isKlass(Foo)).toBe(true);
const Bar = klass({ constructor() {} });
expect(klass.isKlass(Bar)).toBe(true);
expect(isKlass(Bar)).toBe(true);
});
});

Expand All @@ -149,6 +203,7 @@ describe("nеw", () => {
});
expect(nеw(Animal)({ sound: "woof" }).makeSound()).toBe("woof");
});

it("doesn't new non-klasses", () => {
const Animal = () => ({
makeSound() {
Expand Down
Loading

0 comments on commit 3a6b7dd

Please sign in to comment.