Extract parameter types from string literal types with TypeScript

March 27, 2022
Series: Compile Svelte in your head typescripttemplate literal typeconditional type

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?

ts
app.get('/purchase/[shopid]/[itemid]/args/[...args]', (req) => {
const { params } = req;
const params: { shopid: number; itemid: number; args: string[]; }
});
app.get('/docs/[chapter]/[section]', (req) => {
const { params } = req;
const params: { chapter: number; section: number; }
});

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!

ts
app.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;
const params: { username: string; }
}}
/>;

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:

ts
let 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:

ts
let 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:

ts
function 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:

ts
const food = 'sushi';
const food: "sushi"
let drinks = 'beer';
let drinks: string

The same reasoning applies to objects and arrays, as you can mutate the object / array value afterwards, so TypeScript assigns a more generic type:

ts
const object = { food: 'sushi' };
const object: { food: string; }
const array = ['sushi'];
const array: string[]

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

ts
const object = { food: 'sushi' } as const;
const object: { readonly food: "sushi"; }
const array = ['sushi'] as const;
const array: readonly ["sushi"]

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:

ts
const a = 'a';
const b = 'b';
// In JavaScript, you can build a new string
// with template literals
const 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}`;
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:

ts
type Collection<X> = X extends 'arr' ? number[] : Set<number>;
 
type A = Collection<'arr'>;
type A = number[]
// If you pass in something other than 'arr'
type B = Collection<'foo'>;
type B = Set<number>

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 type
type GetReturnValue<X> = X extends () => infer Value ? Value : never;
 
// Here we inferred that `Value` is type `string`
type A = GetReturnValue<() => string>;
type A = string
 
// Here we inferred that `Value` is type `number`
type B = GetReturnValue<() => number>;
type B = 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:

ts
function 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):

ts
function 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

ts
function 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 implementation
function firstElement(arr) {
return arr[0];
}
 
const string = firstElement(['a', 'b', 'c']);
const string: string

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

ts
const obj = firstElement([{ a: 1 }, { a: 3 }, { a: 5 }]);
const obj: { a: number; } | undefined

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 'number'.
Type '{ a: number; }' is not assignable to type 'string | number'. Type '{ a: number; }' is not assignable to type 'number'.
Type '{ a: number; }' is not assignable to type 'string | number'. Type '{ a: number; }' is not assignable to type 'number'.
2322
2322
2322
Type '{ a: number; }' is not assignable to type 'string | number'. Type '{ a: number; }' is not assignable to type 'number'.
Type '{ a: number; }' is not assignable to type 'string | number'. Type '{ a: number; }' is not assignable to type 'number'.
Type '{ a: number; }' is not assignable to type 'string | number'. Type '{ a: number; }' is not assignable to type '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:

  1. Given the type of the path as Path, which is a string literal type,
type Path = '/purchase/[shopid]/[itemid]/args/[...args]';
  1. We derive a new type which has the string break into it's parts [jump here]
type Parts<Path> = 'purchase' | '[shopid]' | '[itemid]' | 'args' | '[...args]';
  1. Filter out the parts to contain only the params [jump here]
type FilteredParts<Path> = '[shopid]' | '[itemid]' | '[...args]';
  1. Remove the brackets [jump here]
type FilteredParts<Path> = 'shopid' | 'itemid' | '...args';
  1. Map the parts into an object type [jump here]
type Params<Path> = {
	shopid: any;
	itemid: any;
	'...args': any;
};
  1. Using Conditional Types to define the map value [jump here]
type Params<Path> = {
	shopid: number;
	itemid: number;
	'...args': string[];
};
  1. Remap keys to remove '...' in ...args [jump here]
type Params<Path> = {
	shopid: number;
	itemid: number;
	args: string[];
};
  1. 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:

ts
type Parts<Path> = Path extends `a/b` ? 'a' | 'b' : never;
type AB = Parts<'a/b'>;
type AB = "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:

ts
type Parts<Path> = Path extends `${infer PartA}/${infer PartB}`
? PartA | PartB
: never;
type AB = Parts<'a/b'>;
type AB = "a" | "b"
type CD = Parts<'c/d'>;
type CD = "c" | "d"
type EFGH = Parts<'ef/gh'>;
type EFGH = "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:

ts
type Parts<Path> = Path extends `${infer PartA}/${infer PartB}`
? PartA | PartB
: Path;
type A = Parts<'a'>;
type A = "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:

ts
type ABCD = Parts<'a/b/c/d'>;
type ABCD = "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:

Step 1: Parts<Path>
ts
type Parts<Path> = Path extends `${infer PartA}/${infer PartB}`
? PartA | Parts<PartB>
: Path;
 
type ABCD = Parts<'a/b/c/d'>;
type ABCD = "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.

ts
type A = 'a' | never;
type A = "a"
type Obj = { a: 1 } | never;
type Obj = { a: 1; }

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:

IsParameter<Part>
ts
type IsParameter<Part> = Part extends `[${infer Anything}]` ? Part : never;
type Purchase = IsParameter<'purchase'>;
type Purchase = never
type ShopId = IsParameter<'[shopid]'>;
type ShopId = "[shopid]"
type ItemId = IsParameter<'[itemid]'>;
type ItemId = "[itemid]"
type Args = IsParameter<'args'>;
type Args = never
type Args2 = IsParameter<'[...args]'>;
type Args2 = "[...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:

Step 2: FilteredParts<Path>
ts
type 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]'>;
type Params = "[shopid]" | "[itemid]" | "[...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 []

Step 3: ParamsWithoutBracket
ts
type 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:

ts
type Params = {
shopid: any,
itemid: any,
'...args': any,
};

If the key type is totally unknown, you can use the Index Signature:

ts
type 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:

ts
type 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:

Step 4: Params<Path>
ts
type 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]'>;
type ParamObject = { shopid: any; itemid: any; "...args": any; }

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 type string[]
  • 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:

ts
type ParamValue<Key> = Key extends `...${infer Anything}` ? string[] : number;
type ShopIdValue = ParamValue<'shopid'>;
type ShopIdValue = number
type ArgValue = ParamValue<'...args'>;
type ArgValue = string[]

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:

ts
type Params<Parts extends string> = {
[Key in Parts]: ParamValue<Key>;
};
type ParamObject = Params<'shopid' | 'itemid' | '...args'>;

is the same as doing

ts
type Params = {
'shopid': ParamValue<'shopid'>;
'itemid': ParamValue<'itemid'>;
'...args': ParamValue<'...args'>;
};

So, adding this on top of the previous step:

Step 5: Params<Path>
ts
type 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]'>;
type ParamObject = { shopid: number; itemid: number; "...args": string[]; }

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:

ts
type RemovePrefixDots<Key> = Key extends `...${infer Name}` ? Name : Key;
type Args = RemovePrefixDots<'...args'>;
type Args = "args"
type ShopId = RemovePrefixDots<'shopid'>;
type ShopId = "shopid"

But to apply this onto our Mapped Type, you can do a Key Remapping via as, which is available from TypeScript 4.1

Step 7: Params<Path>
ts
type 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]'>;
type ParamObject = { shopid: number; itemid: number; args: string[]; }

And there you go!

The Solution

Here's the final solution to the challenge:

Solution
ts
type 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 EmptyArray = []
type StringArray = Parse<'["hello", "world"]'>
type StringArray = ["hello", "world"]
type NestedArray = Parse<'["hello", ["world", "ts", ["!"]], ["try this"]]'>
type NestedArray = ["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

Route component
tsx
import 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;
}}
/>;

References