@autometa/overloaded
v0.3.2
Published
Function and method overloading made easy
Downloads
885
Readme
Overloaded
Overloads as easy to make as they are to use.
Quick Start
Contrived Example:
export function myFunc(a: string): string;
export function myFunc(a: string, b: number): string;
export function myFunc(a: string, b: string): string;
export function myFunc(a: string, b: { a: string; b: number[] }): string;
export function myFunc(a: string, b: MyClassBuilder): MyClass;
export function myFunc(...args: unknown) {
return overloads(
// optional name 'a' // type of 'a' inferred as 'string'
def(string("a")).matches((a) => `hello ${a}`),
// simply declare each argument as you expect it. The first match will
// be executed
def(string("a"), number("b")).matches((a, b) => `hello ${a}`.repeat(b)),
// Verify complex (even nested) objects, arrays, tuples.
def(string("a"), shape({ a: string(), arr: array([number()]) })).matches(
(a, b) => b.arr.map((num) => `${num}: hello ${a}: ${b.a}`)
),
// Check for an instance of a class. Optionally provide a 'shape' as above
def(string("a"), instance("b", MyClassBuilder)).matches(
(name, myClassBuilder) => myClassBuilder.setName(name).build()
),
// validate actual values - useful for configurable, non overloaded functions
// or implementing the strategy pattern.
def(
string("a", { equals: "admin" }),
string("b", { in: ["buyer", "seller"] })
).matches((a) => `hello ${a}`),
// Throw an error if no matching overload, or define a fallback
fallback((...args: unknown[]) => {
// .. do some fallback stuff
})
).use(args);
}
Install
npm add @autometa/overloaded
yarn add @autometa/overloaded
pnpm add @autometa/overloaded
Function & Method Overloads
Function and method overloads are an elegant way to expose multiple definition signatures for a function which help intellisense and display more clear intent about the purpose of your function for a given input.
Imagine a function with the following requirements
- Accepts two values,
a
andb
- Both
a
andb
can be either astring
or anumber
. - If
a
is astring
thenb
must be astring
. - If
a
is anumber
thenb
must be anumber
. - When
a
andb
are string, return a tuple [a, b] - When
a
andb
are numbers, return the sum of the numbers.
This isn't a terribly useful function but that's okay.
The 'simplest' approach is to use discriminated unions (... aren't they all?).
function add(
a: string | number,
b: string | number
): [string, string] | number {
// ...
}
This is fine but unless the above requirements are documented somewhere the consumer will not be able to immediately understand how to use this function.
The solution is overloads. To make this function better for our consumer we can add two new signatures:
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(
a: string | number,
b: string | number
): [string, string] | number {
// ...
}
Now for our consumers this function makes a lot of sense. Once they provide a valid value for a
, the
types b
and return
will automatically be inferred according to our requirements. We can even
document each overload separately.
/**
* blah blah blah
*/
function add(a: string, b: string): [string, string];
/**
* blah blah blah
*/
function add(a: number, b: number): number;
function add(
a: string | number,
b: string | number
): [string, string] | number {
// ...
}
Implementing an overload
While our function is nice to use, implementing it is a bit more grueling. We need to check our types and perform the correct behavior, also accounting for error states:
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(
a: string | number,
b: string | number
): [string, string] | number {
if (typeof a === "string") {
if (typeof b === "string") {
return [a, b];
} else {
throw new Error("a & b must both be strings");
}
}
if (typeof a === "number") {
if (typeof b === "number") {
return a + b;
} else {
throw new Error("a & b must both be numbers");
}
}
throw new Error("unknown types a & b must be string or number");
}
For just two variables with two primitive types, this is pretty rough.
We can imagine then how it might look if a
and b
can be completely different types from each other - including more complex types like objects and arrays which require even more validation.
At some point the the function parameter list becomes unmaintainable at the base level, which is easily resolved with a rest param.
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(a: [string, boolean], b: MyLibOpts): number;
function add(...args: unknown[]): [string, string] | number {}
Okay so how does Overloaded help?
Implementing an overload in Overloaded
Using overloaded to build your function and method overloads is simple.
Call the overloads
function with your param
overloads, then pass
the real arguments.
Overloaded functions are defined by the function pair def
and it's child function match
. Def accepts a rest param array of BaseArguments
.
A BaseArgument
can be created by it's corresponding function. So StringArgument
has a string()
factory function, while BooleanArgument
has the factory function boolean()
. def
returns an object
containing a match
function. match
accepts a function who's parameter
signature will be inferred from the preceding def
.
I.E. for def(string(), string())
, the match
callback
will accept exactly 2 arguments which must be strings:
// ---------------------- V 'a' and 'b' both inferred as 'string'
def(string(), string()).match((a, b)=>}{})
// okay but redundant
def(string(), string()).match((a: string, b: string)=>}{});
// bad - ts error
def(string(), string()).match((a: string, b: number)=>}{});
Overloads can also have a fallback which is executed if no match is found.
Sticking with our original 2 overloads:
import { overloads, def, string, number } from "@autometa/overloaded";
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(
// 'unknown' instead of union is also fine here
...args: (string | number)[]
){
return overloads(
def(string(), string()).match((a, b) => [a, b]),
def(number(), number()).match((a, b) => a + b),
fallback((a, b) => {
const types = [typeof a, typeof b];
const message = `Expected "a" and "b" to be of the same type, either string or number. Found: ${types}`;
throw new Error(message);
})
).use(args);
}
And that's it. We now have an equivalent implementation as what we started with.
The return types for the base function definition will be inferred as a union
of the return types of each match
. If no matching overload is found, an error will be thrown
highlighting each overload and provided context as to why it was not matched.
In the above example, the inferred return type is [string, string] | number
Arguments
We saw Arguments in our above example. They are factory functions named approximately the same as their corresponding type.
Some like the above can be called with no additional arguments. Some require additional context. All argument factories can be named. It is suggested these names match the name in the corresponding overload. Rewriting the above example:
import { overloads, def, string, number } from "@autometa/overloaded";
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(
...args: (string | number)[]
// ...args: unknown[] // generalized alternative
){
return overloads(
def(string('a'), string('b')).match((a, b) => [a, b])
def(number('a'), number('b')).match((a, b) => a + b)
// // if using individual args like (a: string, b: string) pass an array [a, b] to `use`
).use(args);
}
Note: It is not necessary for all overload parameters to share the same names. Likewise it is not necessary overloaded argument factories to share the same name across overloads.
The name is optional, and is used primarily for context when an error is thrown. If no name is provided, the arguments index in the parameter list will be used instead.
Assertions
Assertions are an additional layer of overload matching which can be employed. Validations mean that Overloaded can have useful applications in non-overloaded functions also. Each type factory has its own set of validations which can be used. Using string here as an example:
import { admin, buyer, seller } from "./user-actions";
type UserTypes = "admin" | "buyer" | "seller";
function performUserAction(userType: UserTypes, data: unknown) {
return overloads(
def(string("userType", { equals: "admin" }), unknown("data")).match(
(_userType, data) => {
return admin.action(data);
}
),
def(string("userType", { equals: "buyer" }), unknown("data")).match(
(_userType, data) => {
return buyer.action(data);
}
),
def(string("userType", { equals: "seller" }), unknown("data")).match(
(_userType, data) => {
return seller.action(data);
}
)
).use([userType]);
}
Congratulations. You have implemented... an over-engineered switch statement!
However there are other checks like 'minLength', 'maxLength', 'startsWith' etc which can be composed together to simplify filtering logic for some domains. Each argument type has it's own assertions.
Assertions can of course also be used within an overloaded function or method to provide additional filtering.
Assertions are used for filtering, and do not throw errors by themselves.
Note Some assertions are ignored if an invalid type is passed. For example, most string assertions will not be executed if a number or boolean or object is passed. In that case the assertion executed will be on the type itself.
Fallback
If no overloads match the provided argument an Error will be thrown detailing why each overload failed to match.
Alternatively it is possible to provide a 'fallback' which
will be executed instead of an error being thrown. The fallback
will receive a rest param of arguments with types being unknown
A Fallback is defined with the fallback
function.
import { overloads, def, string, number } from "@autometa/overloaded";
function add(a: string, b: string): [string, string];
function add(a: number, b: number): number;
function add(
...args: (string | number)[] // 'unknown' is a union is also fine here
) {
return overloads(
def(string(), string()).match((a, b) => [a, b]),
def(number(), number()).match((a, b) => a + b),
fallback((...args: unknown[]) => console.log(args))
).use(args);
}
Normalize Constructor or function arguments
declare class MyObject {
constructor(name: string | undefined, widgets: string[] | undefined);
}
function makeMyClass(name: string): MyObject;
function makeMyClass(widgets: string[]): MyObject;
function makeMyClass(name: string, widgets: string[]): MyObject;
function makeMyClass(...args: (string | string[])[]) {
return overloads(
// Create an instance with only a name
def(string("name")).matches((name) => new MyObject(name, undefined)),
// Create an instance with only its list of widgets
def(array("widgets", [string()])).matches(
(name, widgets) => new MyObject(undefined, widgets)
),
// Create an instance with both a name and its list of widgets
def(string("name"), array("widgets", [string()])).matches(
(name, widgets) => new MyObject(name, widgets)
),
// No match - Throw an error
fallback((...args) => {
throw new Error(
`A 'MyObject' instance requires either a name, a list of widgets or both. Received: ${args}`
);
})
).use(args);
}
Factory Function
Convert a plain javascript object to a DTO instance. Pretend
we have a plainToDto
function which creates a new instance of a class
and assigns the values from a raw object to its instance properties
abstract class User {
name: string;
registered: Date;
}
class AdminUser extends User {
permissions: ("read" | "write" | "ban" | "unban" | "sticky")[];
}
class HobbyUser extends User {
interests: string[];
}
class PaidUser extends User {
interests: string[];
tier: number;
badges: number[];
}
// return type inferred as
// AdminUser | HobbyUser | PaidUser
// Throws an error if no match found.
function createUserDto(user: unknown) {
return overloads(
def(shape({ permissions: array([string()]) })).matches((user) => {
return plainToDto(AdminUser, user);
}),
def(shape({ interests: array([string()]) })).matches((user) => {
return plainToDto(HobbyUser, user);
}),
def(shape({ tier: number() })).matches((user) => {
return plainToDto(PaidUser, user);
})
).use([user]);
}
Overload Descriptions
Overloads can take an optional description. Simply provide a string as the first parameter of an overload. This can be used to document the purpose of this particular overload, but won't get lost if things move around like a comment.
function myOverloadedFunction(...args: unknown[]) {
return overloads(
param(
"return a FooWidgetWrapper when the first argument is a FooWidget",
instance(FooWidget)
).matches((widget) => {
// ... do stuff
return myNewFooWidgetWrapper;
})
).use(args);
}
Naming overloads with String Templates
Please be aware the following is kind of cursed
It is possible to name an overload by providing a template string literal before the function call. This can be used in addition to or instead of the description.
Names let you produce overloads that vaguely resemble a function definition.
return overloads(
def`doThingA`(string(), number()).matches((a, b) => {
return a.repeat(b);
}),
def`doThingB`(string(), string()).matches((a, b) => {
return `${a} ${b}`;
})
);
// Using description
return overloads(
def`doThingA`(
"some additional context about doing thing A",
string(),
number()
).matches((a, b) => {
return a.repeat(b);
}),
def`doThingB`(
"some other context about doing thing B",
string(),
string()
).matches((a, b) => {
return `${a} ${b}`;
})
);
Argument Types
Primitives
The "primitives", string
, number
and boolean
are represented by factories
of matching name. Each accepts a name and assertion options.
String
Matches an argument in the same position which is a string.
def(string("a")).matches((a) => `Foo: ${a}`);
Assertions:
equals: Asserts that two strings are exactly equal
def(string("a", { equals: "user" })).matches((a) => UserFactory);
minLength: Asserts that any string passed will have at least n
characters (inclusive), where n
is the value passed to minLength
def(string("a", { minLength: 10 })).matches((a) => `Foo: ${a}`);
// name is optional
def(string({ minLength: 10 })).matches((a) => `Foo: ${a}`);
maxLength: Asserts that any string passed will have at most n
characters (inclusive),
where n
is the value passed to minLength
def(string("a", { maxLength: 10 })).matches((a) => `Foo: ${a}`);
includes: Asserts that any string passed includes some substring.
def(string("a", { includes: "users" })).matches((a) => usersFactory(a));
startsWith: Asserts that a string starts with a specific substring.
def(string("a", { startsWith: "users" })).matches((a) => mySettings[a]);
endsWith: Asserts that a string ends with a specific substring.
def(string("a", { endsWith: "users" })).matches((a) => mySettings[a]);
in: Asserts that a string is part of an array.
def(string("a", { in: ["group1", "group2"] })).matches((a) => this.getGroup(a));
Number
Matches a parameter which of type number
.
Assertions:
min: Asserts that a number has at least some minimum value (inclusive)
def(number("a", { min: 0 })).matches((a) => myArray[a]);
max: Asserts that a number has at most some maximum value (inclusive)
def(number("a", { max: 0 })).matches((a) => throw new Error(`'a' must be positive`));
in: Asserts that a number is part of an array.
def(string("a", { in: [101, 102] })).matches((a) =>
this.courses.enroll.math(a)
);
equals: Asserts that the provided number is exactly equal to the expected.
def(string("a", { equals: 101 })).matches((a) => DalmatianCoatFactory);
types: Asserts that the provided value is either an integer or a float value.
def(string("a", { type: "float" })).matches((a) => a * MY_CONST);
def(string("a", { type: "int" })).matches((a) => MY_ENUM[a]);
boolean assertions
- equals
Arrays and Tuples
Arrays and tuples are similar to each other, but arrays accept a list of possible type options with indeterminate length (unless asserted against), while a tuple is an array of fixed length with deterministic type options.
array
def(array([string(), number()])).match((a: (string | number)[]) => {
// ...
});
Assertions
- minLength
- maxLength
- includes
tuple
def(tuple([string(), number()])).match((a: [string, number]) => {
// ...
});
Assertions
- includes
Shapes
Shapes represent anonymous objects and class instances. It accepts
an object whose keys match the expected object and whose values
are Argument types. By default, only properties with keys defined in the shape
will be used to match.
If the real value passed to the overloaded function contains additional
keys, they will not be considered for validation, unless the exhaustive
option is set to true.
def(shape({ a: string(), b: tuple([number(), boolean()]) })).match(
({ a, b }) => {
console.log(a);
console.log(b[0]);
console.log(b[1]);
}
);
Assertions
- exhaustive
- If true, validation of an overload will fail if the received argument has keys which are not defined by the overload.
- instance
- A Class blueprint/prototype from which the provided value should be extended.
Function
Functions have minimal validation, providing only a shape and an optional argument list length.
def(func<(a: string) => void>()).match((fn) => fn("hi"));
Assertions
- length
- The expected length of the parameter list.
Unknown
Catch-all/wildcard argument with no typing. Will check if the value is defined, which can be overwritten.
def(string(), unknown(), number()).match((a: string, b: unknown, c: number) => {
// ...
});
Date
Matches an instance of the Node Date
class.
import { yesterdayDate } from "./my-date-utils";
def(date({ before: yesterdayDate() })).match((a: Date) => {
// ...
});
Assertions
- before
- Checks that this provided date is chronologically earlier than the configured date
- after
- Checks that this provided date is chronologically later than the configured date
- equal
- Checks that this provided date is chronologically equal to the configured date
Instance
Matches an argument which is an instance of a provided class (also referred to here as a blueprint). Optionally accepts a shape
, which is used to validate the individual
properties of the instance.
def(instance(MyClass, shape({ name: string(), age: number() }))).match(
(a: MyClass) => {
// ...
}
);
Instance assertions can be defined in the shape
argument.