I'm trying to create a generic function in TypeScript that accepts an argument and based on that argument, makes a parameter required and sets its type.
Consider the following:
type definingArg = {val: Array<null | "VAL1" | "VAL2">};
const func = <T extends definingArg>(dArg: T, g: help): void => { ... };
What I'm trying to get is the following:
Depending on the type of dArg
, g
is either optional or required (I can do that using conditional spread arguments) AND dArg
controls the acceptable values of g
. That effectively means I need to somehow create a union based on values present in an array.
This can be untangled like so:
dArg extends [null]
-> g
becomes undefined
AND optionaldArg[number] extends null
-> g
becomes optional, undefined
acceptable (= in union)dArg[number] extends "VAL1"
-> in union: {simpleVal: string}
dArg[number] extends "VAL2"
-> in union: {complex: string, val: number}
Examples:
func( [null], x?: undefined )
func( [null, "VAL1"], x?: undefined | {simpleVal: string} )
func( ["VAL2"], x: {complex: string, val: number} )
Because of other things the function will need to do, I'd very much like to avoid overloading.
TypeScript doesn't have a built-in operator to mark a function parameter as conditionally optional. It's either optional or it's not, and it's a separate syntax. There is a longstanding open feature request at microsoft/TypeScript#12400 to treat all parameters that accept undefined
as optional, but it's not part of the language. So we'll need to work around it.
What we can do is give a function a rest parameter of a a tuple type. There is an equivalence between regular function parameters and rest parameters of tuple types. A call signature like (a: string, b: number) => void
is equivalent to (...args: [string, number]) => void
. And tuples can have optional elements, so (a: string, b?: number) => void
is equivalent to (...args: [string, number?]) => void
. So instead of trying to conditionally make a regular function parameter optional, we can conditionally produce a tuple with an optional element.
So your function might look like
const func = <T extends ⋯>(dArg: ⋯T⋯, ...[x]: ⋯T⋯): void => { };
where the rest parameter is destructured into x
(so inside func
you can still refer to it as x
, if it matters), and the type of the [x]
array depends on the [generic](So if https://www.typescriptlang.org/docs/handbook/2/generics.html) type argument T
in some way we need to figure out.
There are multiple approaches that you can take starting from this basic plan. All of them are at least somewhat clunky. A natural approach is to use conditional types to check T
and do different things depending on it. But it might be a little "neater" to use T
as a key to look up in a data structure:
type ArgMap = {
VAL1: [{ simpleVal: string }],
VAL2: [{ complex: string, val: number }],
null: [undefined?]
}
const func = <T extends null | "VAL1" | "VAL2">(
dArg: Array<T>, ...[x]: ArgMap[`${T}`]
): void => { };
Here T
is the element type of the dArg
parameter, so it's either null
, "VAL1"
, "VAL2"
, or some union of those. Note that "VAL1"
and "VAL2"
are already string literal types and therefore we can use them as keys directly. But null
is less conducive to that. So I've used a template literal type as a trick. If you serialize null
as a template literal type you get the string literal type "null"
. So while T
directly can't be looked up in ArgMap
, the type `${T}`
can.
So we look up the serialized T
in the ArgMap
data structure, and indexed accesses distribute across unions. So if T
is just "VAL1"
then ArgMap[`${T}`]
is [{simpleVal: string}]
. But if it's a union like "VAL1" | "VAL2"
then ArgMap[`${T}`]
is [{simpleVal: string}] | [{ complex: string, val: number }]
. And "null"
will map to [undefined?]
, meaning a tuple with an optional element which could be undefined
if you want.
That has the behavior you desire:
// const func: <null>(dArg: null[], x?: undefined) => void
func([null]); // okay
func([null], undefined);
// const func: <"VAL1" | null>(dArg: ("VAL1" | null)[], x?: { simpleVal: string; }) => void
func([null, "VAL1"], { simpleVal: "abc" }); // okay
func([null, "VAL1"]); // okay
// const func: <"VAL2">(dArg: "VAL2"[], x: { complex: string; val: number; }) => void
func(["VAL2"], { complex: "abc", val: 123 }); // okay
func(["VAL2"]); // error
The presence of null
in the input array makes the second argument optional, and indeed IntelliSense even shows it as a parameter named x
with a ?
after it. The absence of null
in the input array makes the second argument required. And the type of the second argument depends on T
also.
If T
were not sufficiently keylike then a series of conditional types would be your only recourse, something like:
const func = <T extends null | "VAL1" | "VAL2">(
dArg: Array<T>, ...[x]:
T extends null ? [undefined?] :
T extends "VAL1" ? [{ simpleVal: string }] :
T extends "VAL2" ? [{ complex: string, val: number }] :
never
): void => { };
which is wordier but maybe more straightforward for those readers with a similar but slightly different use case. (Note that these are distributive conditional types so each union member of T
will be processed independently of the others.)