"Chainables" enable chain-style extension of class statics in TypeScript.
Compared with class extends
syntax, "chainables" are a lighter mechanism for composition of metadata within constructors. The goal is to allow TypeScript library developers to treat constructors as more of first-class data types. This pattern can serve libraries which aim to track state in user-provided constructors.
Deno users can reference the GitHub source directly (make sure to specify the version).
import_map.json
{
"imports": {
"constructor-chain": "https://github.com/harrysolovay/constructor-chain/blob/v[x.x.x]/src/index.ts"
}
}
Node users can install with npm.
npm install constructor-chain
constructor-chain
is packaged in both ESM & CJS formats, alongside its type definitions.
import { Chainable } from "constructor-chain";
const A = Chainable(
class {
static readonly a = "Hello";
},
);
A.a; // type `"hello"`
new A(); // instance of `A`, which is extended from the anonymous class
const B = A.next({
b: "chainables",
} as const);
B.a; // type `"hello"`
B.b; // type `"chainables"`
new B(); // instance of `B` (subtype of `A`)
const C = B.next({
staticsToString() {
// we can reference inherited statics with `this`
return `${this.a} ${this.b}!`;
},
});
C.staticsToString(); // `Hello chainables!`
new C(); // instance of `C` (subtype of `B`)
Let's say we're building a validation library which accepts a custom String
constructor. Our validation library expects for the constructor to contain metadata about its constraints in the form of static props. This validation metadata could include minLength
, maxLength
, forbiddenChars
, ... the list goes on.
First, we'll define our base constructor, which extends String
.
class OurString extends String {
constructor(value: string) {
super(value);
}
}
Under normal (non-chainable) circumstances, we would extend from OurString
to encode validation metadata onto our constructors.
BAD
class MinLengthString extends OurString {
static readonly minLength = 8;
}
class MaxLengthString extends OurString {
static readonly maxLength = 20;
}
class ForbiddenChars extends OurString {
static readonly forbiddenChars = ["*", ")", ":"];
}
If we want to then recombine these static fields in new constructors, this becomes tedious. We end up writing a lot more than the chainable equivalent:
GOOD
import { Chainable } from "constructor-chain";
const CString = Chainable(OurString);
const MinLengthString = CString.next({ minLength: 8 } as const);
const MaxLengthString = CString.next({ maxLength: 20 } as const);
const ForbiddenCharsString = CString.next({
forbiddenChars: ["*", ")", ":"],
} as const);
These all remain valid constructors.
new MinLengthString("min length string"); // type `string`
new MaxLengthString("max length string"); // type `string`
new ForbiddenCharsString("forbidden chars string"); // type `string`
Let's now abstract over chainables with a few helpers.
import { minLength, maxLength, forbiddenChars } from "./helpers"; // your metadata factories
const CString = Chainable(OurString);
const MinLengthString = CString.next(minLength(8));
const MaxLengthString = CString.next(maxLength(20));
const ForbiddenCharsString = CString.next(forbiddenChars(["*", ")", ":"]));
We can pass metadata down the chain as well.
const MinLengthString = CString.next(minLength(8));
const MinMaxLengthString = MinLengthString.next(maxLength(20));
The chainable constructors can be used to produce new constructors. Statics are represented as an intersection of the initial constructor and a recursive object type, generic over the chain; this representation allows us to overcome the thoughtful, yet sometimes annoying limitations of static properties in TypeScript.
For instance, one cannot write the following without producing TS error 1166 (A computed property name in a class property declaration must refer to an expression whose type is a literal type or a 'unique symbol'
).
BAD
class Base {}
const keys = ["a", "b", "c"] as const;
const [A, B, C] = keys.map((e) => {
return class extends Base {
static [e] = e;
};
});
Let's see how chainables make this possible, without producing a compile error.
GOOD
const Base = Chainable(class {});
const keys = ["a", "b", "c"] as const;
const [A, B, C] = keys.map((e) => Base.next({ [e]: e }));
Caveat
A.a; // type `any`
new A();
B.b; // type `any`
new B();
C.c; // type `any`
new C();
When providing statics to the next
method, the this
context (the aggregate of the chain's contexts) is available.
const A = Chainable(
class {
static readonly fqn = "j3$1Ks";
},
);
const B = A.next({
printFqn() {
console.log(this.fqn);
},
});
B.printFqn(); // logs out "j3$1Ks"
To supplement an alternative method name for next
, pass the desired name as the second argument of Chainable
.
const A = Chainable(
class {
static readonly first = "first";
},
"proceed",
);
// `proceed` instead of `next`
const B = A.proceed({
second: "second",
} as const);
It's my humble belief that its alright if it's unclear how a piece of technology will be used. This library was built primarily out of enjoyment of the process, and its use cases are a secondary consideration. I do believe the Chainable
type has its place, but who knows? TypeScript's statics are unusual beasts: chainables allow us to represent them without actually making use of the type system's representation of statics. This enables us to tackle more composition patterns without compiler errors, in addition to reducing inheritance boilerplate. If you have a use case in mind, or feedback more generally, please do reach out––I'd love to hear from you!
This library is licensed under the Apache 2.0 License.