The Challenge
First of all, here's a TypeScript challenge for you:
Can you figure how to define the TypeScript type for the app.get
method below?
tsapp .get ('/purchase/[shopid]/[itemid]/args/[...args]', (req ) => { const { params } = req ;});app .get ('/docs/[chapter]/[section]', (req ) => { const { params } = req ;});
Try and hover the variables to look at their types.
Notice that ...args
is a string array instead of number 🤯
The req.params
is derived from the string passed in as the 1st parameter.
This is useful when you want to define types for a routing-like function, where you can pass in a route with path pattern that you can define dynamic segments with custom syntax (eg: [shopid]
or :shopid
), and a callback function, where the argument type is derived from the route that you just passed in.
So if you try to access parameter that is not defined, you get an error!
tsapp .get ('/purchase/[shopid]/[itemid]/args/[...args]', (req ) => { const { foo } = req .params ;Property 'foo' does not exist on type '{ shopid: number; itemid: number; args: string[]; }'.2339Property 'foo' does not exist on type '{ shopid: number; itemid: number; args: string[]; }'.});
A real-world use-case for this, if you are more familiar with React Router, is to derive the type for routeProps
in the render function from the path
props:
tsx<Route path ="/user/:username" render ={(routeProps ) => { const params = routeProps .match .params ; }}/>;
In this article, we are going to explore how to define such a type, through various TypeScript techniques, extracting types from a string literal type.
Things you need to know
First thing first, let's talk through some basic knowledges required before we go on and tackle the problem.
String Literal Type
Type string
in TypeScript is a string that can have any value:
tslet str : string = 'abc';str = 'def'; // no errors, string type can have any value
However, a string literal type, is a string type with a specific value:
tslet str : 'abc' = 'abc';str = 'def';Type '"def"' is not assignable to type '"abc"'.2322Type '"def"' is not assignable to type '"abc"'.
Most of the time, we use this alongside with Union Types to determine a list of string values you can pass to a function / array / object:
tsfunction eatSomething (food : 'sushi' | 'ramen') {}eatSomething ('sushi');eatSomething ('ramen');eatSomething ('pencil' );Argument of type '"pencil"' is not assignable to parameter of type '"sushi" | "ramen"'.2345Argument of type '"pencil"' is not assignable to parameter of type '"sushi" | "ramen"'. let food : Array <'sushi' | 'ramen'> = ['sushi'];food .push ('pencil' );Argument of type '"pencil"' is not assignable to parameter of type '"sushi" | "ramen"'.2345Argument of type '"pencil"' is not assignable to parameter of type '"sushi" | "ramen"'. let object : { food : 'sushi' | 'ramen' };object = { food : 'sushi' };object = { food : 'pencil' };Type '"pencil"' is not assignable to type '"sushi" | "ramen"'.2322Type '"pencil"' is not assignable to type '"sushi" | "ramen"'.
So how do you create a string literal type?
When you define a string variable with const
, it is of type string literal. However if you defined it with let
, TypeScript sees that the value of the variable could change, so it assigns the variable to a more generic type:
tsconst food = 'sushi';let drinks = 'beer';
The same reasoning applies to objects and arrays, as you can mutate the object / array value afterwards, so TypeScript assigns a more generic type:
tsconst object = { food : 'sushi' };const array = ['sushi'];
However, you can hint TypeScript that you would only read the value from the object / array and not mutate it, by using the const
assertions
tsconst object = { food : 'sushi' } as const ;const array = ['sushi'] as const ;
Hover over to the object.food
property and you'll see that now the type is a string literal 'sushi'
rather than string
!
Differentiating a string literal type vs a string type allows TypeScript to know not just the type, as well the value of a string.
Template Literal and String Literal Types
Since TypeScript 4.1, TypeScript supports a new way to define a new string literal types, which is to use the familiar syntax of Template literals:
tsconst a = 'a';const b = 'b';// In JavaScript, you can build a new string// with template literalsconst c = `${a } ${b }`; // 'a b' type A = 'a';type B = 'b';// In TypeScript, you can build a new string literal type// with template literals too!type C = `${A } ${B }`;
Conditional Type
Conditional Types allow you to define a type based on another type. In this example, Collection<X>
can be either number[]
or Set<number>
depending on the type of X
:
tstype Collection <X > = X extends 'arr' ? number[] : Set <number>; type A = Collection <'arr'>;// If you pass in something other than 'arr'type B = Collection <'foo'>;
You use the extends
keyword to test if the type X
can be assigned to the type 'arr'
, and conditional operator (condition ? a : b
) to determine the type if it test holds true or otherwise.
If you try to test a more complex type, you can infer parts of the type using the infer
keyword, and define a new type based on the inferred part:
ts// Here you are testing whether X extends `() => ???`// and let TypeScript to infer the `???` part// TypeScript will define a new type called// `Value` for the inferred typetype GetReturnValue <X > = X extends () => infer Value ? Value : never; // Here we inferred that `Value` is type `string`type A = GetReturnValue <() => string>; // Here we inferred that `Value` is type `number`type B = GetReturnValue <() => number>;
Function Overloads and Generic Functions
Whenever you want to define the type of a function in TypeScript, where the argument types and the return type depends on each other, you'll probably will reach out for either Function Overloads or Generic Functions.
What do I meant by having the argument types and return types depending on each other?
Here's an example where the return type is based on the argument type:
tsfunction firstElement (arr ) { return arr [0];} const string = firstElement (['a', 'b', 'c']);const number = firstElement ([1, 2, 3]);
... and here's another example where the 2nd argument type is based on the 1st argument type (argument types depending on each other):
tsfunction calculate (operation , data ) { if (operation === 'add') { return data .addend_1 + data .addend_2 ; } else if (operation === 'divide') { return data .dividend / data .divisor ; }} calculate ('add', { addend_1 : 1, addend_2 : 2 });calculate ('divide', { dividend : 42, divisor : 7 });
So, how do you define a function like this?
If you define
tsfunction firstElement (arr : string[] | number[]): string | number { return arr [0];}
then whatever returned is type string | number
. This doesnt capture the essence of the function, which should return string
if called the function with string[]
and return number
if you called with number[]
.
Instead, you can define the function via function overloads, which is to define multiple function signatures, followed by the implementation:
ts// return string when passed string[]function firstElement (arr : string[]): string;// return number when passed number[]function firstElement (arr : number[]): number;// then the actual implementationfunction firstElement (arr ) { return arr [0];} const string = firstElement (['a', 'b', 'c']);
Alternatively, you can define a generic function, which declares a type parameter, and describe the argument type and return type in terms of the type parameter:
ts// Define type parameter `Item` and describe argument and return type in terms of `Item`function firstElement <Item >(arr : Item []): Item | undefined { return arr [0];}
A plus point for generics is that the Item
type can be any types, and TypeScript can infer what the Item
type represents from the arguments you called the function with, and dictates what the return type should be based on the Item
type
tsconst obj = firstElement ([{ a : 1 }, { a : 3 }, { a : 5 }]);
If you do it with function overload, on the other hand, you'll probably have to define each and every possible function signatures.
But maybe you just want to pass in string[]
or number[]
to firstElement(...)
only, so it's not a problem for function overloads.
Also, you can provide a constraint for the generic function, limiting that the Item
type parameter can only be a certain type, by using the extends
keyword:
ts// `Item` can only be of `string` or `number`function firstElement <Item extends string | number>(arr : Item []): Item | undefined { return arr [0];}const number = firstElement ([1, 3, 5]);const obj = firstElement ([{ a : 1 }, { a : 3 }, { a : 5 }]);Type '{ a: number; }' is not assignable to type 'string | number'.Type '{ a: number; }' is not assignable to type 'string | number'.Type '{ a: number; }' is not assignable to type 'string | number'.2322
2322
2322Type '{ a: number; }' is not assignable to type 'string | number'.Type '{ a: number; }' is not assignable to type 'string | number'.Type '{ a: number; }' is not assignable to type 'string | number'.
Working on the problem
Knowing generic functions, our solution to the problem will probably take the form:
function get<Path extends string>(path: Path, callback: CallbackFn<Path>): void {
// impplementation
}
get('/docs/[chapter]/[section]/args/[...args]', (req) => {
const { params } = req;
});
We use a type parameter Path
, which has to be a string
. The path
argument is of type Path
and the callback will be CallbackFn<Path>
, and the crux of the challenge is to figure out CallbackFn<Path>
.
The Game Plan
So here's the plan:
- Given the type of the path as
Path
, which is a string literal type,
type Path = '/purchase/[shopid]/[itemid]/args/[...args]';
- We derive a new type which has the string break into it's parts [jump here]
type Parts<Path> = 'purchase' | '[shopid]' | '[itemid]' | 'args' | '[...args]';
- Filter out the parts to contain only the params [jump here]
type FilteredParts<Path> = '[shopid]' | '[itemid]' | '[...args]';
- Remove the brackets [jump here]
type FilteredParts<Path> = 'shopid' | 'itemid' | '...args';
- Map the parts into an object type [jump here]
type Params<Path> = {
shopid: any;
itemid: any;
'...args': any;
};
- Using Conditional Types to define the map value [jump here]
type Params<Path> = {
shopid: number;
itemid: number;
'...args': string[];
};
- Remap keys to remove
'...'
in...args
[jump here]
type Params<Path> = {
shopid: number;
itemid: number;
args: string[];
};
- Finally
type CallbackFn<Path> = (req: { params: Params<Path> }) => void;
Splitting a String Literal Type
To split a string literal type, we can use a conditional type to check the value of the string literal:
tstype Parts <Path > = Path extends `a/b` ? 'a' | 'b' : never;type AB = Parts <'a/b'>;
but to take in any string literal, that we have no idea of the value ahead of time,
type CD = Parts<'c/d'>;
type EF = Parts<'e/f'>;
we will have to infer
the value in the conditional tests, and use the inferred value type:
tstype Parts <Path > = Path extends `${infer PartA }/${infer PartB }` ? PartA | PartB : never;type AB = Parts <'a/b'>;type CD = Parts <'c/d'>;type EFGH = Parts <'ef/gh'>;
And if you pass in a string literal that does not match the pattern, we want to return the same string literal type passed in.
So, we return the Path
type in the false
condition branch:
tstype Parts <Path > = Path extends `${infer PartA }/${infer PartB }` ? PartA | PartB : Path ;type A = Parts <'a'>;
At this point, you noticed that PartA
will infer "non-greedily", ie: it will try to infer as much as possible, but do not contain a "/"
character:
tstype ABCD = Parts <'a/b/c/d'>;// type PartA = 'a';// type PartB = 'b/c/d';
So, to split the Path
string literal recursively, we can return the type Parts<PathB>
instead of PathB
:
tstype Parts <Path > = Path extends `${infer PartA }/${infer PartB }` ? PartA | Parts <PartB > : Path ; type ABCD = Parts <'a/b/c/d'>;
Here's the breakdown of what happened:
type Parts<'a/b/c/d'> = 'a' | Parts<'b/c/d'>;
type Parts<'a/b/c/d'> = 'a' | 'b' | Parts<'c/d'>;
type Parts<'a/b/c/d'> = 'a' | 'b' | 'c' | Parts<'d'>;
type Parts<'a/b/c/d'> = 'a' | 'b' | 'c' | 'd';
Filter out only the parts containing the param syntax
The key to this step is the observation that any type unions with never
yields the type itself.
tstype A = 'a' | never;type Obj = { a : 1 } | never;
If we can transform
'purchase' | '[shopid]' | '[itemid]' | 'args' | '[...args]'
into
never | '[shopid]' | '[itemid]' | never | '[...args]'
then we will have
'[shopid]' | '[itemid]' | '[...args]'
So, how, you asked?
Well, we'll have to reach out to conditional types again for help, we can have a conditional type that returns the string literal itself if it starts with [
and ends with ]
, and never
if otherwise:
tstype IsParameter <Part > = Part extends `[${infer Anything }]` ? Part : never;type Purchase = IsParameter <'purchase'>;type ShopId = IsParameter <'[shopid]'>;type ItemId = IsParameter <'[itemid]'>;type Args = IsParameter <'args'>;type Args2 = IsParameter <'[...args]'>;
Although we have no idea what the string content is in between []
, but we can infer it in the conditional type, and we do not have to use the inferred type.
Combining this with the previous step, we have:
tstype IsParameter <Part > = Part extends `[${infer Anything }]` ? Part : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >; type Params = FilteredParts <'/purchase/[shopid]/[itemid]/args/[...args]'>;
Removing the brackets
If you've been following along until this point, you probably have a clearer idea on how we can achieve this step.
So, why not take a pause and try it out in the TypeScript Playground? (I've added the boilerplate code in the link)
To remove the bracket, we can modify the conditional type in the last step, and instead of returning the Part
, we return the inferred type between the []
tstype IsParameter <Part > = Part extends `[${infer ParamName }]` ? ParamName : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >; type ParamsWithoutBracket = FilteredParts <'/purchase/[shopid]/[itemid]/args/[...args]'>;
Map the parts into an Object Type
In this step, we are going to create an Object Types using the result of the previous step as the key.
If you know the key type beforehand, you can create an object type via a type alias:
tstype Params = { shopid : any, itemid : any, '...args': any,};
If the key type is totally unknown, you can use the Index Signature:
tstype Params = { [key : string]: any;};const params : Params = { a : 1, b : 3, shopid : 2 };
However, in our case, the key type is not totally unknown, but it is dynamic. We use Mapped Types which has a similar syntax as the index signature:
tstype Params <Keys extends string> = { [Key in Keys ]: any;}; const params : Params <'shopid' | 'itemid' | '...args'> = { shopid : 2, itemid : 3, '...args': 4,}; const incorrect_keys : Params <'shopid' | 'itemid' | '...args'> = { a : 1,Type '{ a: number; b: number; shopid: number; }' is not assignable to type 'Params<"shopid" | "itemid" | "...args">'.
Object literal may only specify known properties, and 'a' does not exist in type 'Params<"shopid" | "itemid" | "...args">'.2322Type '{ a: number; b: number; shopid: number; }' is not assignable to type 'Params<"shopid" | "itemid" | "...args">'.
Object literal may only specify known properties, and 'a' does not exist in type 'Params<"shopid" | "itemid" | "...args">'. b : 3, shopid : 2,};
Building this on top of the previous step, we have:
tstype IsParameter <Part > = Part extends `[${infer ParamName }]` ? ParamName : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >;type Params <Path > = { [Key in FilteredParts <Path >]: any;}; type ParamObject = Params <'/purchase/[shopid]/[itemid]/args/[...args]'>;
Defining the map value
Now if I ask you to come up with a type that is depending on the key value:
- if it is a string literal type that starts with
...
, return a typestring[]
- else, return a type
number
I hope that your inner voice is shouting Conditional Types!
And yes, we are going to use a Conditional Type:
tstype ParamValue <Key > = Key extends `...${infer Anything }` ? string[] : number;type ShopIdValue = ParamValue <'shopid'>;type ArgValue = ParamValue <'...args'>;
But how do we get the Key
type?
Well, in Mapped Types, when you write { [Key in ???]: any }
, the Key
is the type alias of the key, which you can map it in the value type.
So writing this:
tstype Params <Parts extends string> = { [Key in Parts ]: ParamValue <Key >;};type ParamObject = Params <'shopid' | 'itemid' | '...args'>;
is the same as doing
tstype Params = { 'shopid': ParamValue <'shopid'>; 'itemid': ParamValue <'itemid'>; '...args': ParamValue <'...args'>;};
So, adding this on top of the previous step:
tstype IsParameter <Part > = Part extends `[${infer ParamName }]` ? ParamName : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >;type ParamValue <Key > = Key extends `...${infer Anything }` ? string[] : number;type Params <Path > = { [Key in FilteredParts <Path >]: ParamValue <Key >;}; type ParamObject = Params <'/purchase/[shopid]/[itemid]/args/[...args]'>;
Remap keys to remove '...'
Now the final step. We are going to remove '...'
from the '...args'
key, and I hope you can now proudly come up with the Conditional Types for it:
tstype RemovePrefixDots <Key > = Key extends `...${infer Name }` ? Name : Key ;type Args = RemovePrefixDots <'...args'>;type ShopId = RemovePrefixDots <'shopid'>;
But to apply this onto our Mapped Type, you can do a Key Remapping via as
, which is available from TypeScript 4.1
tstype IsParameter <Part > = Part extends `[${infer ParamName }]` ? ParamName : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >;type ParamValue <Key > = Key extends `...${infer Anything }` ? string[] : number;type RemovePrefixDots <Key > = Key extends `...${infer Name }` ? Name : Key ;type Params <Path > = { [Key in FilteredParts <Path > as RemovePrefixDots <Key >]: ParamValue <Key >;}; type ParamObject = Params <'/purchase/[shopid]/[itemid]/args/[...args]'>;
And there you go!
The Solution
Here's the final solution to the challenge:
tstype IsParameter <Part > = Part extends `[${infer ParamName }]` ? ParamName : never;type FilteredParts <Path > = Path extends `${infer PartA }/${infer PartB }` ? IsParameter <PartA > | FilteredParts <PartB > : IsParameter <Path >;type ParamValue <Key > = Key extends `...${infer Anything }` ? string[] : number;type RemovePrefixDots <Key > = Key extends `...${infer Name }` ? Name : Key ;type Params <Path > = { [Key in FilteredParts <Path > as RemovePrefixDots <Key >]: ParamValue <Key >;};type CallbackFn <Path > = (req : { params : Params <Path > }) => void; function get <Path extends string>(path : Path , callback : CallbackFn <Path >) { // TODO: implement}
Conclusion
I hope this is a fun challenge for you.
As you can see, there's endless possibilities with Conditional Types and Template Literal Types, allowing you to parse and derive types from a string literal type.
Before you go, here's another challenge, see if you can come up with the type for Parse<Str>
:
ts// `Parse` parses string into a nested string array of infinite level deep// type Parse<Str extends string> = ?; type EmptyArray = Parse <'[]'>;type StringArray = Parse <'["hello", "world"]'>type NestedArray = Parse <'["hello", ["world", "ts", ["!"]], ["try this"]]'>
And you just wanna see the answer, here's the link to it
Extra
Here's the type I defined for the Route
component in the example above
tsximport React from 'react'; type PathSegments <Path extends string> = Path extends `${infer SegmentA }/${infer SegmentB }` ? ParamOnly <SegmentA > | PathSegments <SegmentB > : ParamOnly <Path >;type ParamOnly <Segment extends string> = Segment extends `:${infer Param }` ? Param : never;type RouteParams <Path extends string> = { [Key in PathSegments <Path >]: string;}; function Route <Path extends string>({}: { path : Path ; render : (routeProps : { match : { params : RouteParams <Path > }; }) => void;}) { return <div />;} <Route path ="/user/:username" render ={(routeProps ) => { const params = routeProps .match .params ; }}/>;