Search code examples
typescripttscfastifytypescript-compiler-api

Typescript :: in an object, use key string to compute its value type


I have found this fantastic fastify plugin (https://github.com/Coobaha/typed-fastify) that uses the following syntax to infer the correct types passed to the Service, given a schema of type T.

import {Schema, Service} from '@coobaha/typed-fastify';


interface ExampleSchema extends Schema {
  paths: {
    'GET /test/:testId': {
      request: {
        params: {
          testId: {
            type: 'string',
          }
        };
      };
      // other properties, not relevant for question
    };
  };
}

const exampleService: Service<ExampleSchema> = {
  'GET /test/:testId': (req, reply) => {
    const testId = req.params.testId // this passes ✅
    const testName = req.params.testName // this fails 💔 
 
    // rest of handler
  }
};

// example is simplified. Find a full example here: https://github.com/Coobaha/typed-fastify

This plugin is already awesome, but I want to take it even further.

Given a /test/:testId string, we already know which parameters that URL needs. Having to specify it twice (in the URL and the params object), it seems to me something worth trying to automate in order to guarantee they always stay in sync.

I would love to find a way to automatically add the params type, computing it from the key string (the endpoint URL). For example, the key /test/:testId should have a params of type {testId: string}.

Imagine doing:

import {Schema, Service} from '@coobaha/typed-fastify';


interface ExampleSchema extends Schema {
  paths: {
    'GET /test/:testId': {
       // only other properties, no need for explicit params
    };
  };
}

// and still have the same type-checks on the params 🤯:
const exampleService: Service<ExampleSchema> = {
  'GET /test/:testId': (req, reply) => {
    const testId = req.params.testId // this passes ✅
    const testName = req.params.testName // this fails 💔 
 
    // rest of handler
  }
};

In my understanding, this would be impossible with default typescript features and possibly only with a plugin to use at compile time using ttypescript.

Is there any way I can avoid using alternative compilers and writing my own plugins?

Thanks for any support


Solution

  • You can achieve this through template literal types introduced in TypeScript 4.1, though for long paths with many parameters, you'll need TypeScript 4.5 for its detection of tail recursive types: Playground

    type ExtractParams<T extends string, Acc = {}> =
      T extends `${infer _}:${infer P}/${infer R}` ? ExtractParams<R, Acc & { [_ in P]: string }> :
      T extends `${infer _}:${infer P}` ? Acc & { [_ in P]: string } :
      Acc
    
    type Service<Schema extends BaseSchema> = {
      [K in keyof Schema['paths'] & string]: (req: Req<ExtractParams<K>>, reply: Reply) => void;
    }
    
    const exampleService: Service<ExampleSchema> = {
      'GET /test/:testId': (req, reply) => {
        const testId = req.params.testId // this passes ✅
        const testName = req.params.testName // this fails ✅
      }
    };
    

    The magic is in the ExtractParams type. This works by trying two patterns. First, it checks for *:(param)/(rest). If this matches, it adds the inferred param to the accumulator and recurses on rest. This catches parameters in the middle of a query (e.g. GET /test/:HERE/later). Then, it checks for *:(param) to detect parameters at the end (e.g. GET /test/:testId). If neither of these match, then processing has finished and it returns the accumulator, with any inferred parameters.