I have a function that was written in javascript:
function myFunction(param1, param2) {
//do stuff
}
How would I write a typescript declaration file to make sure that param1 is a string that contains {} and param2 is either a number, string, or array of numbers/strings?
For example:
param 1 = "The boat is {}"
param 2 = "yellow"
Example2:
param1: "There are {} out of {} students here"
param2: [20, 30]
Param2 should have exactly the number of elements corresponding to the number of "{}". Param1 must contain at least 1 {}. Return type should be a String
CodePudding user response:
Here's the approach I'd take if I really wanted to try to enforce these constraints at the type level:
declare function myFunction<S extends `${string}{}${string}`>(
param1: S, param2: Param2<S>
): string;
type Param2<S extends string, A extends any[] = []> =
S extends `${string}{}${infer R}` ?
Param2<R, [...A, number | string]> :
A | (A extends [any] ? A[0] : never);
So myFunction
is generic in S
, the type of the param1
parameter, which is constrained to the "pattern template literal type" (as implemented and defined in microsoft/TypeScript#40598) `${string}{}${string}`
, which corresponds to all strings known to contain at least one occurrence of the string "{}"
. Then the type of param2
is Param2<S>
, the intent of which is to represent the correct type of param2
corresponding to param1
. And myFunction()
returns a string
.
The Param2<S>
type is a tail-recursive conditional type that essentially counts the number of "{}"
occurrences in S
and produces a tuple type of exactly that length whose elements are all string | number
(a union type that accepts either string
or number
values)... and futhermore, if that length is 1
, it also accepts a bare string | number
.
The way it works is to accumulate the result in a tuple type parameter A
which starts off as the empty tuple []
. If S
has a "{}"
in it, then we compute Param2<R, [...A, number | string]>
which is the same operation acting on the part of the string R
after the first "{}"
, and with another number | string
element appended to A
(via variadic tuple types). If S
has no "{}"
in it, then we're done and we can return the accumulated A
... and if A
is of length 1
, then we also produce a union of A
with number | string
.
Let's test it. The following calls compile with no error:
myFunction("The boat is {}", "yellow");
myFunction("The boat is {}", ["yellow"]);
myFunction("There are {} out of {} students here", [20, 30]);
myFunction("There are {} out of {} students here and the boat is {}", [20, 30, "yellow"]);
while the following calls all have at least one error:
myFunction("The boat is yellow", []) // error!
// ------> ~~~~~~~~~~~~~~~~~~~~
// Argument of type '"The boat is yellow"' is not assignable to
// parameter of type '`${string}{}${string}`'
myFunction("The boat is {}", ["yellow", "green"]); // error!
// -----------------------------------> ~~~~~~~
// Type 'string' is not assignable to type 'undefined'.
myFunction("There are {} out of {} students here", 20); // error!
// ----------------------------------------------> ~~
// Argument of type 'number' is not assignable to
// parameter of type '[string | number, string | number]'
myFunction("There are {} out of {} students here", [20, 30, 40]); // error!
// ----------------------------------------------> ~~~~~~~~~~~~
// Source has 3 element(s) but target allows only 2
That all makes sense. In the first case param1
doesn't have even a single "{}"
in it, so it's an error. In the rest of the cases, param2
has either too many or too few elements.
So there you go, that's what you've asked for. Note that this might be more trouble than it's worth, depending on your use cases. It's a fairly complicated bit of typing, and it requires the compiler keep track of the exact literal type of the value you pass in for param1
. TypeScript will often take string literals and widen their types to just string
since heuristically speaking people often don't care about literal types. For example:
let someString = "The boat is {}";
// let someString: string;
Here I've used let
, which prompts the compiler to widen the variable to string
assuming I might re-assign it later. Then you write this and get an error:
myFunction(someString, "yellow"); // error!
// Argument of type 'string' is not assignable to
// parameter of type '`${string}{}${string}`'
Even though this would be fine at runtime, the compiler complains; it has forgotten what value is in someString
and therefore cannot be sure it has a "{}"
in it. Maybe that's fine for your use cases, but you should be aware that it has the potential to be quite annoying to use.