Resources to types with 'infer'

Slawomir MoriakSlawomir Moriak
10 min read

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.

๐Ÿ’ก
Tip: Even though importing .json file content won't work with this solution, perhaps it would be worth considering moving JSON content to .ts file if it is static. Therefore project could benefit from all the types inference.

What does it mean for value to be const in compile time?

  1. Values must be written as strings directly in code files. It can be a standalone value or a table of values.

  2. 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:

  1. Provide all the data in .ts file as const table of values.

  2. Define the conditional type, that infers specific type values based on the data convention, and map it to any type structure you need.

  3. 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 generalize this solution to match any resource id, as long as we keep it in known format.

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;
  1. 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' }
    
  2. 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' }
     //                                     } 
     //                                  }
    
  3. 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
         )
    
  4. 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;
     //        };
     //    };
     //}
    
๐Ÿ“‘
You can go even more down the rabbit hole and try extract query parameters out of the url if any are given. If you wish to see the more complex implementation of this type, then go ahead to my GitHub repository, and see by yourself.

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:

  1. Data needs to be in well known and documented format.

  2. Infering types is more limited than parsing data in the runtime. Complex cases can be troublesome.

  3. 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:

  1. If your data is unformatted, then it might be either you do not understand it fully or there were some flaws in resources modeling.

  2. 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.

  3. 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 :)

0
Subscribe to my newsletter

Read articles from Slawomir Moriak directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Slawomir Moriak
Slawomir Moriak

Developer with 12+ years of experience in wide range business branches and wide range of technologies orbiting around .Net on backend, TS on frontend and AWS/Azure cloud on infrastructure. Lover of discussing programming and architecture related concepts. Writing to share thoughts, way of thinking and dealing with everyday challenges at work. Maybe teaching someone something new and useful this way.