Search code examples
typescript

Transforming function's argument types, and inconsistency between Mapped types (works) and Recursive type (does not work)


I've been fiddling with some transformation of arguments of a function via a recursive type, have some issue to make the type work, I made same simplified example to highlight the issue, together with examples using mapped type (for this toy example mapped type would be enough): Playground

First I just create Transform functions using both mapped types and recursive type, with test data and tests to ensure that these return the same result.

type TestItems = [string, number, string, boolean]

export type MappedTransform<UnionWith, Items extends any[]> 
  = { [I in keyof Items]: Items[I] | UnionWith }

type RecursiveTransform<UnionWith, Items extends any[]> 
  = Items extends [infer Head, ...infer Tail]
  ? [Head | UnionWith, ...RecursiveTransform<UnionWith, Tail>]
  : [];

type mappedTypes = MappedTransform<undefined, TestItems>

type recursiveTypes = RecursiveTransform<undefined, TestItems>

Then I have a function that uses transform via mapped type. I suppose that UnionWith type gets plugged in as first type argument wheres second argument is filled with tuple type with types of arguments of the function. This creates a correct type.

export const fnWithMappedType = <UnionWith extends any, Args extends any[]>
  (data: UnionWith, ...args: MappedTransform<UnionWith, Args>) => {}

fnWithMappedType(undefined, "first", 42, true)

Next is the problem part: The types plugged into recursive type transformer the same way as above, into type that behaves same way in isolation in test examples behaves differently, and results in empty tuple, so that function accepts only the very first parameter unlike all four in previous example.

export const fnWithRecursiveType = <UnionWith extends any, Args extends any[]>
  (data: UnionWith, ...args: RecursiveTransform<UnionWith, Args>) => {}

fnWithRecursiveType(undefined, "first", 42, true)

Is there some hidden gotcha or did I miss something, can this be solved so that arguments may be transformed by recursive type?


Solution

  • In general, TypeScript cannot infer generic type arguments from type functions on the relevant type parameter. That is, if you've got a generic function that looks like

    declare function fn<T>(u: F<T>): T;
    

    where F<T> is some type function like type F<T> = ⋯, and you call that function with an argument of some type U like:

    declare const u: U;
    const t = fn(u);
    

    then you're asking TypeScript to invert F<T>. Meaning you want TypeScript to compute something like type F⁻¹<U> = ⋯, such that F<F⁻¹<U>> is U no matter what U is.

    But TypeScript cannot do this in general. There are only specific forms of type functions TypeScript knows how to invert. Even if we ignore cases where it is impossible, even in principle to invert (like type F<T> = string which doesn't depend on T), type functions can be very complicated, and inverting them is no simple matter. Especially if those type functions involve conditional types. Imagine

    type F<T extends string, A extends string = ""> =
      T extends `${infer L}.${infer R}` ? F<R, `${L}${A}${L}`> : `${T}${A}${T}`
    

    What's type F⁻¹<U> = ? If I told you that U is "zzzazazazazzazzzza", how easy is it for you to figure out what T was? It's not impossible (and in fact there are several possibilities but one "best" possibility), but it's not something we can expect TypeScript to just do.


    One supported case is when F<T> is a homomorphic mapped type (see What does "homomorphic mapped type" mean?) of the form type F<T> = {[K in keyof T]: ⋯T[K]⋯}. This is documented in the v1 TypeScript Handbook as "inference from mapped types" but doesn't seem to be mentioned in the current version of the TypeScript Handbook. Anyway, this is easy for the compiler because it can infer from F<T> if it can infer from the properties of F<T>, since each property in the input translates directly to a property in the output. Since mapped types transform tuples to tuples that means that for a tuple type, T can be inferred from F<T> on an element-by-element basis. So MappedTransform works.

    On the other hand, your RecursiveTransform does not, because you're asking TypeScript to iterate backwards through a recursive conditional type. This just doesn't work in general, and TypeScript doesn't even try. The inference fails and you get the behavior you're seeing. Your choices here are either to manually invert the type function so that something starting with

    declare const fnWithRecursiveType:
      <U extends any, A extends any[]>(data: U, ...args: RecursiveTransform<U, A>) => A
    

    would change to

    type InvertedRecursiveTransform<U, V> = ⋯
    
    declare const fnWithRecursiveType:
      <U extends any, V extends any[]>(data: U, ...args: V) => InvertedRecursiveTransform<U, V>
    

    OR, if your recursive transform is a no-op when A is valid (that is, if A extends RecursiveTransform<U, A> when A is correct) then you can use an intersection (which is a way to get around ms/TS#7234) of the form

    declare const fnWithRecursiveType:
      <U extends any, A extends any[]>(data: U, ...args: A & RecursiveTransform<U, A>) => A;
    

    Playground link to code