How to type a function (in typescript) so that the input "arrayness" preserves in the output.
If input is array, output is array too. If input is a single value, output is a single value too.
Now there's an error saying that the output type can be both an array or a single value, and one of the options doesn't match. However it's clear what that type should be. How can it be expressed?
interface Target {
src: string | string[];
dest: string;
}
const forwardSlash = (s: string) => s.replace(/\\/g, '/');
const fixTargetPathsForViteStaticCopyOnWindows = (targets: Target[]) => {
const transformListOrSingleValue = <T,>(x: T | T[], f: (a: T) => T): T | T[] => {
return Array.isArray(x) ? x.map(f) : f(x);
};
const fixSingleTarget = (target: Target): Target => {
return {
...target,
src: transformListOrSingleValue(target.src, forwardSlash),
dest: transformListOrSingleValue(target.dest, forwardSlash), //Type 'string | string[]' is not assignable to type 'string'.
};
};
return targets.map(fixSingleTarget);
};
The error:
Type 'string | string[]' is not assignable to type 'string'.
Type 'string[]' is not assignable to type 'string'.ts(2322)
index.d.ts(28, 5): The expected type comes from property 'dest' which is declared here on type 'Target
PS Although this variation resolves my problem,
const forwardSlash = (s: string) => s.replace(/\\/g, '/');
const transformTargetsToForwardSlashFormat = (targets: Target[]) => {
const transformListOrSingleValue = <T>(x: T | T[], f: (a: T) => T): T | T[] => {
return Array.isArray(x) ? x.map(f) : f(x);
};
const fixSingleTarget = (target: Target): Target => {
return {
...target,
src: transformListOrSingleValue(target.src, forwardSlash),
dest: forwardSlash(target.dest),
};
};
return targets.map(fixSingleTarget);
};
I still would like to know if it's doable in the initial way.
If you want the return type of a function to depend on whether or not one of its inputs is an array, you will either need to use conditional types with generics, or you will need to use overloads to represent the different allowable ways to call the function. Note that all of this is more complex than just having separate code paths for arrays and non-arrays. Trying to put those two fundamentally distinct operations in one function involves type juggling.
Here's how you'd use generics with conditional types:
declare function transformListOrSingleValue<T>(
x: T,
f: (a: T extends readonly any[] ? T[number] : T) => T extends readonly any[] ? T[number] : T
): T extends readonly any[] ? T[number][] : T;
Here T
always corresponds to the type of the x
input. If it is an array, then the conditional types of the form T extends readonly any[] ? 𝒴 : 𝒩
will resolve to their 𝒴
branches, while if it is not, they will resolve to their 𝒩 branches. And because those are distributive conditional types, if x
's type is a union of an array and non-array types, they will resolve to the union of the branches, or 𝒴 | 𝒩
.
So if T
is an array type, the call signature resolves to <T>(x: T, f: (a: T[number])=>T[number]) => T[number][]
. Note that T[number]
, the indexed access into T
with the number
index, is the element type of T
. So f
becomes a function that takes and returns an element type of T
. And the return type T[number][]
means "an array of the element types of T
". You might want to simplify that just to T
, but it's not always identical. If T
is an array type of the form X[]
, then T[number]
is X
, and T[number][]
is X[]
, so it's just T
. But if T
is a tuple type like [X, Y, Z]
, then T[number]
is X | Y | Z
and T[number][]
is Array<X | Y | Z>
, which is not just T
. We can't be sure that a heterogenous array input will return a heterogeneous array output of the same exact shape. what if x
is [true, false]
of type [true, false]
, and f
is x => !x
. Then the output is [false, true]
which is not of type [true, false]
, but it is of type boolean[]
. If you don't want to worry about this, you can replace T[number][]
with T
. (But do not replace it with T[number]
, because then you are explicitly not preserving "arrayness", and an array input will become a non-array output.)
If T
is not an array type, the call signature resolves to <T>(x: T, f: (a: T)=>T) => T
, as desired. And if T
is a union then you get a union.
And here's how you'd do it with overloads:
declare function transformListOrSingleValue<T>(x: T[], f: (a: T) => T): T[];
declare function transformListOrSingleValue<T>(x: T, f: (a: T) => T): T;
declare function transformListOrSingleValue<T>(x: T | T[], f: (a: T) => T): T | T[];
This is, in some sense, more straightforward. It gives three call signatures for three different ways you might call the function. The first call signature handles array cases. The second call signature handles non-array cases (and note that "non-array" isn't specified explicitly, but is implied because array cases will not reach that call signature). The third call signature handles union cases. Note that you need an explicit call signature for that case; it is not automatically handled by a combination of the first two call signatures. There's a longstanding feature request at microsoft/TypeScript#14107 for such automatic union handling, but it's not part of the language.
Let's test both approaches on some examples:
const fn = (a: { z: string }) => ({ z: a.z.toUpperCase() })
const arr = transformListOrSingleValue([{ z: "a" }, { z: "b" }], fn);
// ^? const arr: { z: string; }[]
const oneVal = transformListOrSingleValue({ z: "c" }, fn);
// ^? const oneVal: { z: string; }
const union = transformListOrSingleValue(Math.random() < 0.5 ? { z: "d" } : [{ z: "e" }], fn);
// ^? const union: { z: string; } | { z: string; }[]
Looks good. Notice how the "arrayness" is preserved where expected.
As for the implementation of transformListOrSingleValue()
, you'll never be able to get TypeScript to follow the logic that Array.isArray(x) ? x.map(f) : f(x)
actually meets the call signatures. So you'll need to use something like a type assertion or the like to loosen the checking. Overloaded function statements are already loosened, so you can do that:
// generic call signature
function transformListOrSingleValue<T>(
x: T,
f: (a: T extends readonly any[] ? T[number] : T) => T extends readonly any[] ? T[number] : T
): T extends readonly any[] ? T[number][] : T;
// implementation
function transformListOrSingleValue<T>(x: T | T[], f: (a: T) => T): T | T[] {
return Array.isArray(x) ? x.map(f) : f(x);
};
or
// overload call signatures
function transformListOrSingleValue<T>(x: T[], f: (a: T) => T): T[];
function transformListOrSingleValue<T>(x: T, f: (a: T) => T): T;
function transformListOrSingleValue<T>(x: T | T[], f: (a: T) => T): T | T[];
// implementation
function transformListOrSingleValue<T>(x: T | T[], f: (a: T) => T): T | T[] {
return Array.isArray(x) ? x.map(f) : f(x);
};
Note that the implementation is completely separate from the call signatures. The callers see only the call signatures, and not the implementation signature. So the fact that the implementation handles the union case is not visible externally unless you have a call signature that also handles it. Hence the apparent repetition. If we left out the third call signature in the overload case, then you couldn't call the function with a union at all, no matter what the implementation signature is.