Complex Typing in TypeScript
Data typing is usually simple, but with functional techniques, recursion, and more, it can become complex. This article will complement the previous one on partial application, by developing full typing in TypeScript.
In the previous article in this series, we learned how to do partial application in JavaScript, and developed a higher-order partial()
function… but what about a TypeScript version?
It happens that typing our function is not trivial, and requires several interesting techniques, so in this article we’ll complete the work from the previous article, going from JavaScript to TypeScript, and adding full type controls. (For more detail, you can check out my Mastering JavaScript Functional Programming book, in which I also discuss currying and other transformations.) You may want to look at our code again, to have it fresh in mind.
Check matching types
When analyzing the provided and missing parameters, we must check that types agree among them, one by one. However, when defining types we cannot just write an if
or a loop, so we’ll have to find a workaround: our declaration will use ternary operators and recursion in place of if
s and loops.
We will write a TypesMatch<P,A>
type declaration (P
stands for “parameters”, and A
for “arguments”; both will be tuples with types) that will either produce boolean
(if P
and A
matched) or never
(if there was a mismatch). The declaration is as follows:
type TypesMatch<
P extends any[],
A extends any[]
> = 0 extends P["length"]
? boolean 1️⃣
: 0 extends A["length"]
? boolean 2️⃣
: [P, A] extends [
[infer PH, ...infer PT],
[infer AH, ...infer AT]?
]
? AH extends undefined
? TypesMatch<PT, AT> 3️⃣
: PH extends AH
? TypesMatch<PT, AT> 4️⃣
: never
: never;
The 0 extends P["length"]
line 1️⃣ may confuse you, but it’s the way of checking if the length of P
is zero. We use infer
and spreading to separate the first types of P
and A
(PH
and AH
; H
is for “head”) from the rest (PT
and AT
; T
is for “tail”).
Our type is as follows:
- if either
P
1️⃣ orA
2️⃣ is empty, returnboolean
- if the first type in
A
is undefined 3️⃣ or if the first type inA
matches the first type inP
4️⃣ discard the first type inP
, discard the first type inA
, and recursively analyze the remaining types - otherwise (if the first type in
A
is not undefined, but doesn’t match the first type inP
) returnnever
We can do some quick tests to verify everything is in order — the “ok” types are boolean
( meaning they are correct) and the “bad” types are never
(so they are wrong):
type ok1 = TypesMatch<
[boolean, number, string],
[undefined, undefined, undefined]
>;
type ok2 = TypesMatch<
[boolean, number | string, string],
[boolean, undefined, string]
>;
type ok3 = TypesMatch<
[boolean, number | string, string],
[boolean, string, string]
>;
type bad1 = TypesMatch<
[boolean, number, string],
[undefined, string, number]
>;
type bad2 = TypesMatch<
[boolean, number | string, string],
[string, undefined, string]
>;
type bad3 = TypesMatch<
[boolean, number | string, string],
[boolean, boolean, string]
>;
Deduce pending parameters
We’ll need another auxiliary type, Partialize<P,A>
, that will receive a tuple with the parameter types and another with the argument types, and produce a tuple with the types for the still not provided arguments; i.e., those types in P
for which there’s a corresponding undefined
type in A
. Let’s assume we already checked that types match (as we saw in the previous section). We can write types as follows.
type Partialize<
P extends any[],
A extends any[]
> = 0 extends P["length"]
? [] 1️⃣
: 0 extends A["length"]
? P 2️⃣
: [P, A] extends [
[infer PH, ...infer PT],
[infer AH, ...infer AT]
]
? AH extends undefined
? [PH, ...Partialize<PT, AT>] 3️⃣
: [...Partialize<PT, AT>] 4️⃣
: never;
The type definition is very similar in style to TypesMatch
, though the results vary.
- 1️⃣ if
P
is empty (all parameters were provided) the result is empty as well - 2️⃣ if
A
is empty (no arguments, all parameters are pending), the result isP
- 3️⃣ if the first type in
A
is undefined, the result will be the first type inP
(because it wasn´t provided) followed by the result of processing the rest of the types inP
andA
. - 4️⃣ otherwise, if the first type in
P
matches the first type inA
, the result will be whatever is returned by comparing the rest of the types inP
to the rest of the types inA
We can check how Partialize
works:
type part1 = Partialize<
[boolean, number, string],
[undefined, undefined, undefined]
>; // boolean,number,string
type part2 = Partialize<
[boolean | string, number, string],
[undefined, number, undefined]
>; // boolean|string,string
type part3 = Partialize<
[boolean | string, number, string],
[boolean, undefined, string]
>; // number
type part4 = Partialize<
[boolean, number, string],
[boolean, number, string]
>; // empty!
Session Replay for Developers
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.
Complete typing
Now that we have a way to check that the parameters and arguments match, and also how to calculate the still pending parameters, we can actually write our Partial
type declaration.
In the definition below, P
will represent the types of the function’s parameters, A
the types of the provided arguments, and R
the type of the function’s result.
type Partial<P extends any[], R> = <A extends any[]>(
...x: A
) => TypesMatch<P, A> extends never
? never
: P extends any[]
? 0 extends Partialize<P, A>["length"]
? R
: Partial<Partialize<P, A>, R>
: never;
How does this work?
- if the types in
P
andA
do not match, we return anever
result, which means there was a problem - if
A
is empty (no arguments provided) then our partialized function will take parameters of typeP
and produce a result of typeR
- if
A
is not empty, then we compute the result of comparing typesP
andA
, and that will be the types of the parameters to our partialized function, that will produce a result of typeR
This data type definition would be very hard to understand on its own, but since we already saw all its components, it’s understandable.
Finish the job
OK, it seems we’re ready to finish now! Here’s the full definition of partial(...)
in TypeScript — and we’ll have to deal with another typing detail! The issue will be that TypeScript, though usually being able to deduce types on its own, isn’t able to work out how our function works and what it returns, so we’ll have to do a trick to help it.
function partial<P extends any[], R>( 1️⃣
fn: (...a: P) => R
): Partial<P, R>;
function partial(fn: (...a: any) => any) { 2️⃣
const partialize =
(...args1: any[]) =>
(...args2: any[]) => {
for (
let i = 0;
i < args1.length && args2.length;
i++
) {
if (args1[i] === undefined) {
args1[i] = args2.shift();
}
}
const allParams = [...args1, ...args2];
return allParams.includes(undefined) ||
allParams.length < fn.length
? partialize(...allParams)
: fn(...allParams);
};
return partialize();
}
We are defining the type of partial()
twice: once using our typing 1️⃣ and the second time 2️⃣ with generic any
values. The issue is that TypeScript cannot “understand” the function enough to work out the result, so we “overload” the type definition, and then it’s our responsibility to ensure data types are correct!
Let’s finish with a few examples of fully typed partial application at work.
const nonsense = partial(function (
a: number,
b: string,
c: boolean
) {
return `${a}/${b}/${c}`;
});
const ns1 = nonsense(undefined, "9", undefined);
// type: Partial<[number, boolean], string>
const ns2 = nonsense(22, "9", undefined);
// type: Partial<[boolean], string>
const ns3 = nonsense(22, "9", true);
// type: string -- its value is "22/9/true"
const ns4 = nonsense(undefined,"X",undefined)(undefined,false);
// type: Partial<[number], string>
We can check types:
ns1
fixed the 2nd parameter of thenonsense
function, so we now have a[number,boolean]
tostring
function.ns2
fixed two parameters, so the result is a[boolean]
tostring
ns3
fixes all parameters, so the result is a plainstring
ns4
fixes parameters in two steps; the result is a[number]
tostring
function
Conclusion
This was an example of complex typing; we needed to use recursion instead of loops, ternary operators instead of alternative structures, and produce a type instead of returning a value. We also saw that, even with all this work, TypeScript can have problems determining types, but there is a way out.