@hypercubed/dynamo
v1.0.2
Published
Fast dynamic method dispatch in typeScript using decorators
Downloads
6
Readme
Dynamo
Fast dynamic method dispatch in TypeScript. Easy to read and understand decorators-based function definitions are converted to runtime multimethods. Avoids nasty runtime type checking and produces correctly typed methods.
Introduction
- Compose multiple method signatures into a correctly typed dynamic dispatch function (multimethods).
- Runtime type-checking of function arguments based on TypeScript type annotations (when possible).
- Custom defined types coercions.
- Easily supports union types,
any
type, and variable arguments. - Excellent mechanism for type constraints.
- Extensively benchmarked and micro-optimized.
Requires
experimentalDecorators
andemitDecoratorMetadata
be enabled in yourtsconfig.json
.
TLDR Usage
import { Dynamo, guard, conversion, signature } from '@hypercubed/dynamo';
const dynamo = new Dynamo();
class Complex {
@guard()
static isComplex(a: any): a is Complex {
return a instanceof Complex;
}
@conversion()
static fromNumber(a: number): Complex {
return new Complex(a, 0);
}
constructor(public re: number, public im: number) {}
add(b: Complex): Complex {
const re = this.re + b.re;
const im = this.re + b.im;
return new Complex(re, im);
}
}
dynamo.add(Complex);
class Add {
name: 'add';
@signature()
number(a: number, b: number): number {
return a + b;
}
complex(a: number | Complex, b: number | Complex): Complex;
@signature()
complex(a: Complex, b: Complex): Complex {
return a.add(b);
}
}
// typed as `((number, number) => number) & ((number | Complex, number | Complex) => Complex)`
const add = dynamo.function(Add);
add(3, 6); // 9
add(new Complex(3, 0), new Complex(0, 6)); // Complex(3, 6)
add(3, new Complex(0, 6)); // Complex(3, 6)
// @ts-ignore
add(3, '6'); // TypeError
Usage Explanation
Dynamo instance
Start by creating a Dynamo
environment. Types and conversions are local to this instance.
import { Dynamo, guard, conversion, signature, Any } from '@hypercubed/dynamo';
const dynamo = new Dynamo();
The Dynamo
constructor also accepts an options object with the following options:
types
- Instead of adding default types, uses this object or array of objects. Passingfalse
allows you to have no default types.autoadd
- Ifautoadd
is true, when unknown types are encountered (either as a conversion or in a function signature) Dynamo will add them automatically. If the type does not have a@guard
defined aninstanceof X
guard will be used.
Signatures
Dynamic methods are defined using a class with one or more @signature
decorators and the dynamo.function
method. The first method matching a argument signature is evaluated.
class Add {
@signature()
strings(a: string, b: string): string {
return a + ' ' + b;
}
@signature()
numbers(a: number, a: number): number {
return a + b;
}
}
// correctly typed as `((a: string, a: string) => string & (a: number, a: number) => number)`
const add = dynamo.function(Add);
add(20, 22); // 42
add('Hello', 'World'); // "Hello World"
// @ts-ignore
add('Hello', 42); // TypeError
This library uses metadata reflections to infer types from the TypeScript type annotations. Since TypeScript only supports basic type serialization only basic types can be inferred. Basic types defined by default are the primitives number
, string
, boolean
and the constructors Array
, Function
, Date
, and RegExp
. Types that are class constructors are are also supported but must be defined per Dynamo
instance (see types below).
TypeScript serializes both undefined
and null
as void 0
, so these types should be explicitly listed in the signature. Use the predefined class Any
for unknown
or any
.
class Inspect {
@signature(undefined)
inspectString(a: undefined): string {
return 'a is undefined';
}
@signature(null)
inspectNull(a: null): string {
return 'a is null';
}
@signature(Any)
inspectAny(a: unknown): string {
return 'a is something';
}
}
// correctly typed as `((a: undefined) => string & (a: null) => string & (a: unknown) => string)`
const inspect = dynamo.function(Inspect);
inspect(undefined); // 'a is undefined'
inspect(null); // 'a is null'
inspect('string'); // 'a is something'
Other types (including any
, unknown
, union types, and interfaces) are treated as Object
by TypeScript type serialization. To support more complex types the input parameter signatures must be supplied to the signature
decorator. For type unions use an array. When listing explicit signatures for primitives used the built-in constructors.
class Add {
@signature()
addNumbers(a: number, b: number): string {
return a + b;
}
@signature(String, [Number, String])
addStrings(a: string, b: number | string): string {
return '' + a + ' ' + b;
}
}
// correctly typed as `((a: string, b: number | string) => string) & (a: number, a: number) => number)`
const add = dynamo.function(Add);
add(20, 22); // 42
add('Hello', 'World'); // 'Hello World'
add('Hello', 42); // 'Hello 42'
// @ts-ignore
add(20, 'World'); // TypeError
Signatures are inherited:
class AddNumber {
@signature()
addNumbers(a: number, a: number): number {
return a + b;
}
}
class AddStrings extends AddNumber {
@signature(String, [Number, String])
addStrings(a: string, b: number | string): string {
return '' + a + ' ' + b;
}
}
// has the type of `((a: number, a: number) => number) & ((a: string, b: number | string) => string)`
const add = dynamo.function(Print);
add(20, 22); // 42
add('Hello', 'World'); // 'Hello World'
add('Hello', 42); // 'Hello 42'
// @ts-ignore
add(20, 'World'); // TypeError
Note that the type of the resulting function is determined by the TypeScript type annotations for each method, regardless if the @signature
decorator was applied to it. However, the runtime function only includes the methods to which @signature
was applied.
Types
Runtime types are defined using the @guard
decorator and added to a dynamo instance using dynamo.add
. Guards are defined using static methods on a class and should be pure functions returning a boolean. Types (guards) must be explictly assoaciated with a dynamo
instance (unless using autoadd
) and must be added to each dynamo
instance it will be used.
class Complex {
@guard()
static isComplex(x: unknown): x is Complex {
return x instanceof Complex;
}
}
dynamo.add(Complex);
dynamo.add
also works as a decorator:
@dynamo.add
class Complex {
@guard()
static isComplex(x: unknown): x is Complex {
return x instanceof Complex;
}
}
Type Constraints
You can add runtime constraints to primitives by extending the primitive constructor.
class Integer extends Number {
@guard()
static isInteger(x: unknown): x is Integer {
return typeof x === 'number' && Number.isInteger(x);
}
}
dynamo.add(Integer);
Guards defined on classes are inherited.
class Integer extends Number {
@guard()
static isInteger(x: unknown): x is Integer {
return typeof x === 'number' && Number.isInteger(x);
}
}
class Even extends Integer {
@guard()
static isEven(x: number): x is Even {
// isInteger guard on `Integer` is invoked before isEven
return x % 2 === 0;
}
}
dynamo.add(Even);
In the examples above the runtime type guards exists on the class itself, this is the default when no argumenst are bassed to the guard
decorator. Guards can be added for other classes by passing the class to the guard
decorator.
import Decimal from 'decimal.js';
class Numbers {
@guard(Decimal)
static isDecimal(x: unknown): x is Decimal {
return x instanceof Decimal;
}
@guard(BigInt)
static isBigInt(x: unknown): x is BigInt {
return typeof x === 'bigint';
}
}
dynamo.add(Numbers);
In these cases the definitions are not attached to the type class.
Complex Types and Interfaces
As mentioned above, TypeScript does not serialize complex types, for example this will not work as expected since TypeScript will output the type metadata for the parameter a
as Object
.
class Fn {
@signature()
nope(a: string | string[]): string {
return 'Nope';
}
}
A solution for this is to define a class that can act as the type definition for string | string[]
similar to adding constraints as discussed above.
class StringOrStringArray {
@guard()
static isStringArray(a: unknown): boolean {
return Array.isArray(a) ? x.every(x => typeof x === 'string') : typeof x === 'string';
}
}
dynamo.add(StringOrStringArray);
class Fn {
@signature(StringOrStringArrayGuard)
ok(a: string | string[]): string {
return 'ok';
}
}
Using the following trick we can define a type that will serialize correctly by TypeScript and minimize redundancy.
class StringOrStringArrayGuard {
@guard()
static isStringArray(a: unknown): a is (string | string[]) {
return Array.isArray(a) ? a.every(x => typeof x === 'string') : typeof a === 'string';
}
}
// tslint:disable-next-line:variable-name
const StringOrStringArray = StringOrStringArrayGuard;
type StringOrStringArray = string | string[];
dynamo.add(StringOrStringArray);
class Fn {
@signature()
ok(a: StringOrStringArray): string {
return 'ok';
}
}
This will work for interfaces as well.
interface IPerson {
name: Name;
age: Age;
}
class PersonGuard {
@guard()
static isPerson(x: unknown): x is IPerson {
return typeof x === 'object' && 'name' in x && 'age' in x;
}
}
// tslint:disable-next-line:variable-name
const Person = PersonGuard;
type Person = IPerson;
dynamo.add(Person);
class GetName {
@signature()
getName(person: Person): Name {
return person.name;
}
}
Conversions
Runtime conversions (coursions) are added using the @conversion
decorator and the dynamo.add
method (or as a decorator).
class Complex {
@guard()
static isComplex(a: any): a is Complex {
return a instanceof Complex;
}
@conversion()
static fromNumber(a: number): Complex {
return new Complex(a, 0);
}
constructor(public re: number, public im: number) {}
}
dynamo.add(Complex);
When defining the function, add an override to the type to get the correct TypeScript definition, Dynamo will handle the conversion.
class add {
name = 'add';
add(a: number | Complex, b: number | Complex);
@signature()
add(a: Complex, b: Complex): Complex {
return a.abb(b);
}
}
// typed as (a: number | Complex, b: number | Complex) => Complex
const add = dynamo.function(Add);
As mentioned above, methods are invoked with priority from top to bottom. Note in this case the number
method is evoked if both arguments are numbers, the complex method is invoked when one or both are are Complex
instances.
class add {
name = 'add';
@signature()
number(a: number, b: number): number {
return a + b;
}
complex(a: number | Complex, b: number | Complex);
@signature()
complex(a: Complex, b: Complex): Complex {
return a.abb(b);
}
}
// typed as `((a: number, b: number) => number & (a: number | Complex, b: number | Complex) => Complex)`
const add = dynamo.function(Add);
add(20, 22); // 42
add(new Complex(20, 0), new Complex(0, 22)); // Complex(20, 22)
times(20, new Complex(0, 22)); // Complex(20, 22)
// @ts-ignore
times(20, '22'); // TypeError
License
This project is licensed under the MIT License - see the LICENSE file for details