In this article you will find some useful examples of typing factory functions which requires you to use several generics with constraints
Sometimes it is hard to get around with typescript generics. Especially when we want to apply some sort of type validation.
First thing that we should keep in mind, is that they are resolved from left to right.
Let's imagine a case where we need to implement a factory functions. Function should expect two arguments. First one methods
expects a dictionary where key
is a method name and value
is a method which have an access to this. If user provides two key/value
pairs as a first argument, this
of each method should be aware about the other one.
factory({
a: function () {
this.id // string
const b = this.b(); // Ok
return "0";
},
b: function () {
const b = this.b(); // Ok
this.c() // expected error, [c] does not exists
return 0;
},
}, function () {
this.id // string
const a = this.a() // string
const b = this.b(); // number
});
As you might have noticed, second argument method
should be just a function which can access any of these methods. Also, there should be shared property id
Since we have all our requirements, we can try to type it. Because property id
should be accessible from any argument, we can create base type.
type Base = { id: string }
Now, when we know that this
of each method should implementBase
type it is easy to type a prototype of afactory
function
const factory = <
T extends Base,
Methods extends Record<string, (this: T) => unknown>,
>(methods: Methods, method: (this: T) => void) => {
return null as any
}
We still unable to access this.a
and this.b
factory({
a: function () {
const b = this.b(); // error
return "0";
},
b: function () {
const b = this.b(); // error
return 0;
},
}, function () {
const a = this.a() // error
const b = this.b(); // error
});
Let's brainstorm Methods
generic. Each methods this
should be aware of each method. It means that this
should be an intersection of Base
type and Methods
itself
const factory = <
T extends Base,
Methods extends Record<string, (this: T & Methods) => unknown>,
>(methods: Methods, method: (this: T & Methods) => void) => {
return null as any
}
factory(
{
a: function () { }, // error
},
function () { }
);
But, it does not work in a way we expect. The 'this' types of each signature are incompatible.
This is not trivial.
factory(
{
a: function () { }, // is a context sensitive function
},
function () { }
);
Function a
is context-sensitive. In order to better understand this type of problem, please seethis answer of A_blop user.
Here you can find related article
As you might have guessed, in order to fix it we need to provide extra generic Self
type Base = { id: string }
const factory = <
T extends Base,
Methods extends Record<string, <Self extends Methods>(this: T & Self) => unknown>,
>(methods: Methods, method: (this: T & Methods) => void) => {
return null as any
}
factory({
a: function () {
const b = this.b(); // Ok
return "0";
},
b: function () {
const b = this.b(); // Ok
return 0;
},
}, function () {
const a = this.a() // string
const b = this.b(); // number
});
If you are interested in runtime implementation of factory
function and other problems/issues you may face please seethisandthisanswers.
Btw,here you can find interesting use case. Code example from the question can be rewritten as follow:
interface BasicProps<T> {
input: T;
}
interface TransformProps<T, O> {
transform: (input: T) => O;
render: <U extends O>(input: U) => any; // added extra generic U
}
type TProps<T, O> = BasicProps<T> & TransformProps<T, O>;
function test<T, O>(props: TProps<T, O>) {
return props.render(props.transform(props.input));
}
const testData = {
nested: {
test: 1,
best: true
}
};
const testResult = test({
input: testData,
transform: input => input.nested,
render: input => input.test // Error
});
The error still exists. TS i unable to figure out the type of render argument input
. Once you rewrite render
function to make it void
- TS is able to infer input
argument. This is strange. I'm unable to explain it.
const testResult = test({
input: testData,
transform: input => input.nested,
render: input => {
input.test // ok
}
});
Imagine that we have simple high order function that accepts another function and some object and returns another function:
const hof = (callback, data) => (model) => callback({ ...data, ...model });
The main requirements here is that we need exclude from model
properties that already present in data
.
Let's type it step by step. Our callback
argument can be any one argument function, so we can use simple restriction:(arg: any) => void
. The second argument data
can be a Partial
of callback
argument. I meanParameters<Callback>[0]. Let's implement it.
const factory = <
Callback extends (arg: any) => void,
Arg extends Parameters<Callback>[0],
KnownProps extends Partial<Arg>,
>(callback: Callback, knownProps: KnownProps) => (...[newProps]: any) =>
callback({ ...knownProps, ...newProps });
Now we can type our second part.
interface GreeterData {
greetings: string;
userName: string;
}
const factory = <
Callback extends (arg: any) => void,
Arg extends Parameters<Callback>[0],
KnownProps extends Partial<Arg>,
>(callback: Callback, knownProps: KnownProps) =>
(newProps: Omit<Arg, keyof KnownProps>) =>
callback({ ...knownProps, ...newProps });
const greeter = (greeterData: GreeterData) => string;
const greet1 = factory(greeter, { greetings: "hello" });
greet1({ userName: 'a' })
greet1({ userName: 'a', greetings: 'a' }) // expected error
The argument of inner function meets our main requirement. However, this example has few extra requirements. If you are curious, you can find full codein the answer provided byDima Parzhitsky.
Btw, he is an author of famous answer about using project references in TypeScript 3.0
P.S. Here you can find an interesting example of typing Map
constraints.