Search code examples
typescripttypescript-generics

dynamically call a typed function


I'm using the the library https://github.com/ivanhofer/typesafe-i18n

This library generates strongly typed translation information and functions, like below. (the following examples are simplified)

export type MyTranslations = {
  Hello: (arg: { field: unknown}) => string
  Bye: (arg: { field: unknown, date: unknown}) => string
  Foo: (arg: { field: unknown}) => unknown
  Bar: (arg: { max: unknown}) => unknown,
  test: string // this is just here to show that not every property of MyTranslations needs to be a function
}

const translations: MyTranslations = {
  Hello: (arg: { field: unknown}) => 'hello',
  Bye: (arg: { field: unknown, date: unknown}) => 'bye',
  Foo: (arg: { field: unknown}) => 'foo',
  Bar: (arg: { max: unknown}) => 'bar',
  test: '' // this is just here to show that not every property of MyTranslations needs to be a function
}

Now in my code I have a function which should translate messages dynamically, it does not know exactly what I has to translate.
Through TS typing information it knows what I might translate (with keyof).
Here is the code so far.
I spend already quite some time and I'm not sure if it is even possible or sensible, but I just want to know :)

// preparation
interface MyParams {
  [index: string]: boolean | number | string | undefined
  field?: keyof MyTranslations
}

interface Result {
  transKey: keyof MyTranslations,
  params?: MyParams
}

const results: Result[] = [
  {
    transKey: 'Hello',
    params: {
      field: 'Bye'
    }
  },
  {
    transKey: 'Bar',
    params: {
      max: 'test'
    }
  }
] 

type PickByType<T, V> = {
  [P in keyof T as T[P] extends V | undefined ? P : never]: T[P]
}

the translation function

function translate(results: Result[]) {
  results.forEach((result: Result) => {
      type A = PickByType<MyTranslations, Function>
      type C = keyof A
     
      if(result.params) {
        type T = typeof result.params
        type Req = Required<T>

        const req = result.params as Req
        
        const func = translations[result.transKey]
        type F = typeof func
        

        const f = translations as A
        f[result.transKey as C](req)

      }
  })
}

translate(results)

The problem is here f[result.transKey as C](req)

Error

Argument of type 'Required<MyParams>' is not assignable to parameter of type '{ field: unknown; } & { field: unknown; date: unknown; } & { field: unknown; } & { max: unknown; }'.
  Property 'date' is missing in type 'Required<MyParams>' but required in type '{ field: unknown; date: unknown; }'

Which makes sense. Typescript expects an intersection type.
So I thought maybe I can create this type somehow (holding all the required parameters field, max and date and then, according to this type information create a new object of this new type holding this information, like so in pseudo code

type D = getAllParametersFromTypeAsIntersectionType() // <- this is easy
const newParams = createNewParamsAsTypeD(result.params)

Any ideas?

TS Playground


Solution

  • You don't really want to treat result.params as an intersection type. First of all, it's not one: it would need to have every single property (e.g., {field: ⋯, date: ⋯, max: ⋯}) but in practice you're only passing the properties needed by translations[result.transKey] for the particular result.transKey. The reason TypeScript expects and intersection is because it has no idea about the intended higher order relationship between result.transKey and result.params. Your Result type doesn't actually encode any such relationship (you could write { transKey: 'Hello', params: { max: 'Bye' } } and it would be accepted, even though that's not the right params type for Hello). And even if you did encode it as a union of acceptable types for each transKey, it wouldn't automatically work inside the forEach() callback because TypeScript can't deal well with "correlated unions".

    This lack of direct support for correlated unions is covered in microsoft/TypeScript#30581. The recommended approach is to refactor to use generics in a particular way, as described in microsoft/TypeScript#47109.

    The idea is to write a "base" object type that represents the underlying key-value relationship you care about, and then all your operations should use that type, generic indexes into that type, and generic indexes into mapped types over that type.

    Your base object type would be

    interface TransArg {
        Hello: { field: unknown; };
        Bye: { field: unknown; date: unknown; };
        Foo: { field: unknown; };
        Bar: { max: unknown; };
    }
    

    You can actually compute this from MyTranslations as follows:

    type TransKey = {
      [K in keyof MyTranslations]: MyTranslations[K] extends (arg: any) => any ? K : never
    }[keyof MyTranslations]
    // type TransKey = "Hello" | "Bye" | "Foo" | "Bar"
    
    type TransArg = { [K in TransKey]: Parameters<MyTranslations[K]>[0] }
    

    The TransKey thing is essentially keyof PickByType<MyTranslations, Function> in your version. Note that this is all just to avoid the test key, which is sort of a distraction here from your main issue, but it's easily overcome so that's fine.

    And then TransArg maps over TransKey to grab just the parameter type for the method. Now we need to rewrite the type of translations in terms of TransArg, as follows:

    const _translations: { [K in TransKey]: (arg: TransArg[K]) => void } =
      translations;
    

    This isn't really doing anything other than verifying that translations is of that mapped type, but now we can use _translations in place of translations and the compiler will be better able to follow what it does for arbitrary key K, since it's explicitly encoded in the type (as opposed to MyTranslations which only has such information implicitly).

    We can now write Result more accurately as a distributive object type (as coined in ms/TS#47109):

    type Result<K extends TransKey = TransKey> =
      { [P in K]: { transKey: P, params?: TransArg[P] } }[K]
    

    So Result<K> for a particular K is just the type of Result suitable for that transKey. And Result<TransKey> is the full union of Result<K> for each K in TransKey. And the default type argument TransKey means that writing just Result gives us that full union. Now you can write

    const results: Result[] = [
      {
        transKey: 'Hello',
        params: {
          field: 'Bye'
        }
      },
      {
        transKey: 'Bar',
        params: {
          max: 'test'
        }
      }
    ]
    

    And if you tried to mix up the params there (e.g., use {max: 'test'} for 'Hello') you'd get an error.

    We're almost done. Now we can call results.forEach() with a generic callback function:

    function translate(results: Result[]) {
      results.forEach(<K extends TransKey>(result: Result<K>) => {
        if (result.params) _translations[result.transKey](result.params);
      })
    }
    

    Inside the callback, _translations[result.transKey] is of the single generic type (arg: TransArg[K]) => void, while the type of result.params is TransArg[K] (well, it was TransArg[K] | undefined but we've eliminated undefined by the check if (result.params)). And so you have a single function type that accepts an argument corresponding exactly to the argument we're passing it. So that compiles with no problem.

    Playground link to code