Learn how you can make any string-based resource into a TypeScript type, and make the compiler do the hard work of checking the data for you. The magic of pattern matching in conditional types with the help of infer
keyword is the topic for today.
Case
Sometimes in our projects, we get some amount of data described with common conventions shared by team members or given by the source format. Be it something as simple as csv rows; as familiar for web devs as url; or more abstract as a complex compound identifier.
It would be great to have access to all these values at compilation time. Have the IDE supporting them and auto-completing values, making typos and other man-made mistakes impossible.
In TypeScript it can be done by matching smaller string values from larger format and extracting them to their own types.
Theory
To get the concept you need to understand a few concepts TypeScript operates with.
Const literal types
Before we go to examples we need to keep in mind one condition: Values we want to match must be given as const in the compile time. Therefore it is not suitable for working with dynamic runtime data like API call results or even imported file content.
What does it mean for value to be const in compile time?
Values must be written as strings directly in code files. It can be a standalone value or a table of values.
They must be declared with the use of
as const
instruction.const johnData = "John;Johnson;United Kingdom;" as const; const clientsData = [ JohnData, "Marie;Demarie;Canada", "Andrew;Kowalski;USA" ] as const;
It all tells the compiler, that it should treat values declared here literally. Their values are fixed, so they can not change at any point in the code, hence if we create a type from them compiler would narrow it to a literal type with this exact value.
type ClientData = typeof johnData; // exactly: "John;Johnson;United Kingdom;"
Conditional types
In TypeScript you can use ternary operator in types declaration to change one type to multiple others based on condition. You can chain them if you want.
type Motion<T extends Animal> = T extends Fish ? 'swim'
: (T extends Bird ? 'fly' : 'walk')
You can also return never type, which basically means this type will removed from result after compilation.
type FlyingAnimal<T extends Animal> = Motion<T> extends 'fly' ? T : never
// only flying animals in this type
Template literal types with infer
Going back to our literal string types, you must know, that TypeScript allows us to match them with the use of a pattern similar to the one known from JavaScript and string interpolation. Just the other way around.
If you had once formatted your text like this in js:
const name = "John";
const surname = "Johnson";
const country = "United Kingdom";
const myInterpolatedJohnString = `${name};${surname};${country};`;
Given the exact value of type, you can extract these values into other literal types. You need to do this within the type condition and use infer
keyword in the pattern:
// infer literal values from const value to own types
type ClientData<T extends string> =
T exends `${infer Name};${infer Surname};${infer Country}`
? {
name: Name,
surname: Surname,
country: Country
}
: never;
type JohnClient = ClientData<typeof johnData>
// {
// name: "John",
// surname: "Johnson",
// country: "United Kingdom"
// }
Practice
We have already matched our first CSV-like data to type. We have John now available as type and typescript will not let us use any other values for it. No Marie or Andrew will jump in his place.
const serviceForJohn = (john: JohnClient) => {
// this is exclusive function for John Johnson only ;)
...
}
We can match multiple clients with use of type indexing:
type Client = ClientData<clientsData[number]> // iterate over all clients
const serviceForClient = (client: Client) => {
// John, Marie and Andrew can use this function
}
From several data rows, we created a nicely structured type. And if we add new rows to our clientsData
table (assuming we follow a defined string matching pattern), we will get the next client available in the type values list.
And for each of these clients, typescript will only allow us to put in exact specific client data. John will always be Johnson from UK and not Demarie from USA. This is due to the fact that TypeScript creates union type out of all the matching rows.
Data type recipe
Let's summarize the process of constructing types out of set of data:
Provide all the data in .ts file as const table of values.
Define the conditional type, that infers specific type values based on the data convention, and map it to any type structure you need.
Apply the type to indexed const values.
We will now use all of this knowledge this knowledge to implement two more complex cases.
Cloud resources as types
Lots of the services now are cloud based. Be it big companies or small private side projects, they all can utilize cloud resources and infrastructure. And sometimes we as developers there need to access these resources.
These resources have identifiers assigned to them, and if we mistake it How good would it be to have compiler track the values for us, so we do not worry about it ever again.
For example Azure resources identifiers are formatted like this:
/subscriptions/{subscriptionId}/providers/{resourceProvider}/{resourceType}/{resourceName}
And AWS resource names like this:
arn:partition:service:region:account-id:resource-type:resource-id
The code below creates structured type out of resource strings:
type ExtractAzureResource<T extends string>
= T extends `/subscriptions/${infer SubscriptionId}/resourceGroups/${infer ResourceGroupId}/providers/${infer Provider}/${infer Category}/${infer ResourceId}`
? {
subscription: SubscriptionId,
resourceGroup: ResourceGroupId,
provider: Provider,
category: Category,
resourceId: ResourceId
}
: never;
type ExtractAWSResource<T extends string>
= T extends `arn:${infer Partition}:${infer Service}:${infer Region}:${infer Account}:${infer Type}:${infer ResourceId}`
? {
partition: Partition,
service: Service,
region: Region,
account: Account,
type: Type,
resourceId: ResourceId
}
: never;
We can use that also to extract only values we need like regions or service types:
const AzureResourcePaths = [
'/subscriptions/MySuperSubscription/resourceGroups/MyAwesomeResourceGroup/providers/Micsoroft.Compute/disks/MyCMainHDD',
'/subscriptions/MySuperSubscription/resourceGroups/MyAwesomeResourceGroup/providers/Micsoroft.Web/c/MyServicePlan',
'/subscriptions/Production/resourceGroups/SuperServiceResources/providers/Micsoroft.Storage/storageAccounts/ServiceMainStorage',
] as const
type AzureService
= ExtractAzureResource<typeof AzureResourcePaths[number]>['category']
// AzureService = "disks" | "serverFarms" | "storageAccounts"
And we can merge several types into generalized one. By combining ternary conditional operator we match multiple patterns and extract exactly what we need:
type SimpleResource<T extends string> =
T extends `/subscriptions/${infer _}/resourceGroups/${infer __}/providers/${infer ___}/${infer Category}/${infer ResourceId}`
? {
type: 'azure',
service: Category,
id: ResourceId,
fullId: T
}
: (
T extends `arn:${infer _}:${infer __}:${infer ___}:${infer ____}:${infer ResourceType}:${infer ResourceId}`
? {
type: 'aws',
service: ResourceType,
id: ResourceId,
fullId: T
}
: never
);
Dependent on the case this might be very useful for performing operation on data with common values standardized. And we always have fullId
if needed.
Routes with params as types
Now let take a look at something more appealing for Front-End developers: routes. We use them all the time to describe navigation between different parts of application. To pass some state within them. To use history feature for seamless browser integration. And more other things.
Often we create complex definition of routes object in or code, to have access to every part of the path, fill in parameters, append it, modify it etc. Wouldn't it be nice if compiler did it all for us?
We will use what we learned and add even more sophisticated type programming to achieve full route to type mapping. Will do it step by step, so we can all understand the process:
// Routes examples
export const ROUTES = [
"home",
"blog/{articleId:integer}",
"blog/{articleId:integer}/edit",
"contact/form"
] as const;
Map single path element
Simply divide string value by first slash '/' found in it and return both parts of path. Or return passed string if no slash was found.
type ExtractRoute<T extends string> = T extends `${infer PathSegment}/${infer Rest}` ? { segment: PathSegment, rest: Rest } : { segment: T } type Route = ExtractRoute<ROUTES[number]>; // home -> { segment: 'home' } // blog/{articleId} -> { segment: 'blog' , rest: '{articleId}' } // blog/{articleId}/edit -> { segment: 'blog' , rest: '{articleId}/edit' } // contact/form-> { segment: 'contact', rest: 'form' }
Nest mapping of paths
Reuse the same
ExtractRoute
type to the rest of the path after slash. This way we will recursively go through next segments and put them in the structure.type ExtractRoute<T extends string> = T extends `${infer PathSegment}/${infer OtherSegment}` ? { segment: PathSegment, next: ExtractRoute<OtherSegment> } : { segment: T } type Route = ExtractRoute<ROUTES[number]>; // home -> { segment: 'home' } // blog/{articleId:integer} -> { segment: 'blog', // next: { segment: '{articleId:integer}' } // } // blog/{articleId:integer}/edit -> { segment: 'blog', // next: { // segment: '{articleId:integer}', // next: { segment: 'edit' } // } // }
Extract route parameters
Create type for matching parameters in routes. Depending on the format these parameters are marked in path you might need to redefine the pattern.
// parameter prefixed by colon ':parameter' type ExtractPathParameter<T extends string> = T extends `:\{infer Param}` ? { name: Param } : never // parameter formatted with curly braces: '{parameter:type}' type ExtractPathParameter<T extends string> = T extends `\{${infer Param}:${infer Type}\}` ? { name: Param, type: Type } : (T extends `\{${infer Param}\}` ? { name: Param //, type: 'string' // can set default type if preferred } : never )
Combine types together
Add parameter value to the structured route type and extract it via new type definition. If not matched then 'undefined' will be used.
type ExtractRoute<T extends string> = T extends `${infer PathSegment}/${infer OtherSegment}` ? { segment: PathSegment, parameter?: ExtractPathParameter<PathSegment>, next: ExtractRoute<OtherSegment> } : { segment: T, parameter?: ExtractPathParameter<PathSegment> } type BlogEditRoute = ExtractRoute<typeof ROUTES[2]> // type BlogEditRoute = { // segment: "blog"; // parameter?: undefined; // next: { // segment: "{articleId:integer}"; // parameter?: { // name: "articleId"; // type: "integer" // }; // next: { // segment: "edit"; // parameter?: undefined; // }; // }; //}
Now having your routes defined in some file, just run them through the type, and let compiler do the hard work. Then just use them to access to all route paths segments, parameters etc.
Lessons learned
Presented here possibility allows for facilitation of many specific use-cases without engaging runtime project resources.
But there are few caveats we need to remember:
Data needs to be in well known and documented format.
Infering types is more limited than parsing data in the runtime. Complex cases can be troublesome.
You will not know if any data was not processed properly unless you will try to use it. Compiler won't inform you about data, that was not processed, it will be removed from result silently.
On the other hand there are counterarguments to the above:
If your data is unformatted, then it might be either you do not understand it fully or there were some flaws in resources modeling.
Parsing something specific from flat string can still be very complex in runtime and requires extensive testing to get all exceptional cases right. Running these tests is still more costly (time- and resource-wise), than just having it done during development by compiler.
There are actually techniques, which can help you test your types. Whether they return any value for specific data, or if they do not result with compilation error. This is an interesting topic deserving own article one day. But you are not left alone with checking the types.
Next time, I have the ability to work with some flat data in my Front-End project, I will try to model it as a type (unless there is a library that already does the parsing). There isn't much to lose here, and the IntelliSense makes up for the more complex type implementation, I will probably end up with, in comparison to runtime parser. But maybe not.
Let me know if you found this article helpful, and have some idea to use it on your own. Gab the link to my GitHub repo with practical examples, and have fun learning :)