Home > front end >  Inferring a generic tuple with spread expressions in typescript
Inferring a generic tuple with spread expressions in typescript

Time:09-17

(TS version - 4.1)

I'm trying to a type a generic function that can accept an array/tuple of any length; each element would have the same property names but different possible types.

type Param<X = unknown, Y = unknown> = {
  x?: X
  y: Y
}

// return type is a fixed tuple: for each element, returns x if defined, otherwise y
function func(arg: Param[]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y )
} 

Example:

func([ {x: 'string', y: 123} ])
func([ {x: 'string', y: 123}, {x: 123, y: 'string' } ])

*update: updated the function to clarify why I care about the Xs and Ys explicitly: the actual return type is a tuple whose individual elements can be either X or Y depending on the original arg

What I'm really trying to get at is for the compiler to infer the length and element type of each argument, so that the result is typed accordingly.

I've tried a couple of things

  • Creating a couple of helpers to extract the nested types from Param<X,Y>
type InferX<T extends [...any[]]> = {
  [K in keyof T] : T[K] extends Param<infer X, any> ? X : never
}
type InferY<T extends [...any[]]> = {
  [K in keyof T] : T[K] extends Param<any, infer Y> ? Y : never
}
  • Giving the function some type parameters to include the nested types, and representing the function argument as a spread expression, mapped to these generic types
// this works when I provide explicit type parameters, but won't infer them implicitly from the arguments.. assumes everything is `unknown` i.e. func<Param<unknown, unknown>[], unknown[], unknown[]>
function func<
  T extends Param[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }]
) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y ) as [
    ...{ [K in keyof T]: T[K] extends {x: X[K]} ? X[K] : Y[K] }
  ]
}

// application 1: works ok
func<[{x: string, y: number}, {x: number, y: string}]>([{x: 'string', y: 123}, {x: 123, y: 'string'}])

// application 2: doesn't work - types are unknown
func([{x: 'string', y: 123}])

// when I try to get more explicit with type T it won't compile at all- `A rest element type must be an array type.` error on the spread arg types
function func<
  T extends { [K in keyof T]: Param<X[K], Y[K]> } & any[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }] // ERROR
) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y ) as [
    ...{ [K in keyof T]: T[K] extends {x: X[K]} ? X[K] : Y[K] }
  ]
}

Wondering if anybody has any ideas how to get the inference to work correctly/am I thinking about this the wrong way?

Thanks!

CodePudding user response:

For best results, make type parameter inference as easy as possible for the compiler

When you call a generic function without manually specifying its type parameters, the compiler needs to infer these type parameters, often from values passed as parameters to the function. Let's say we have a function whose call signature is

// type F<T> = ...
declare function foo<T>(x: F<T>): void;

And we call it like this:

// declare const someValue: ...
foo(someValue);

Then the compiler's job is to determine T based on the type of someValue and the definition of F<T>. The compiler has to try to invert F, and deduce T from F<T>. The easier that is, the more likely it is that the inferred type parameter matches your expectations.

In your case, you have something like

function func<
  T extends Param[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }]
): void;

but the compiler is unable to infer T, X, and Y from a value of type [...{ [K in keyof T]: Param<X[K], Y[K]> }]. It's just too much type manipulation for the compiler to do. You will have better luck if you do something simpler like

function func<T extends Param[]>(arg: T): void;

Variadic tuple types in function parameter types give the compiler a hint to infer tuples instead of just arrays

This works, but the compiler tends to infer array types instead of tuple types:

declare function func<T extends Param[]>(arg: T): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }]) 
// T inferred as Array<{x: string, y: number} | {x: number, y: string}>

Since you want T to be inferred as a tuple type and not just an array type, you can use variadic tuple types to get this behavior, by changing arg: T to arg: [...T] or arg: readonly [...T]:

declare function func<T extends Param[]>(arg: [...T]): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }]) 
// T inferred as [{ x: string; y: number;}, { x: number; y: string;}]

Calculating the return type: no annotation yields unknown[]

Now there's the issue of what to do with the output type. It's not void. Let's examine the implementation of the example code:

function func<T extends Param[]>(arg: [...T]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y)
} 

If we don't annotate the return type, it will be determined by the compiler to be unknown[]; the map() method of arrays doesn't preserve tuple lengths, and it certainly can't follow the higher order logic that would produce different types for different indices. (See Mapping tuple-typed value to different tuple-typed value without casts for more information.)

So you'd get unknown[]:

const p: Param<string, number> = (
  [{ x: "", y: 1 }, { y: 1 }, { x: undefined, y: 1 }]
)[Math.floor(Math.random() * 3)]

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: unknown[]

which is true but useless for you. Since the compiler can't infer a strong type for the return value of map(), we will need to assert it as something that's a function of T. But what function of T?


Calculating the return type: a union of the x and y properties at each element

Well, for each numeric index I, you looking at the element T[I] and returning either its "x" property of type T[I]["x"] or its "y" property of type T[I]["y"]. So a first pass at this would be to return the union of these types:

// here E is some element of the T array
type XorY<E> = E extends Param ? E["x"] | E["y"] : never;

function func<T extends Param[]>(arg: [...T]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y) as
    { [I in keyof T]: XorY<T[I]> }
}

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: [string | number, unknown, number | undefined, string | number | undefined]

That's better; we have a tuple of length four, and the string and number types are in there for the most part. But there are a few drawbacks.

One drawback is that the compiler doesn't realize that if x exists you'll definitely get it; presumably for the first element of result the type should be string, not string | number.

Another is that if x is not known to exist, the type unknown is coming out. This is actually technically correct; the compiler cannot know that the type it infers for {y: 1} means that the x property is definitely missing. The type {y: number} does not mean "no properties but y exist"; it just means "no known properties but y exist". (That is, object types are not "exact" in the sense requested in microsoft/TypeScript#12936.) So the compiler decides T[I]['x'] is unknown, which wrecks things. Let's do the technically-incorrect-but-convenient thing and say that a type like {y: 1} means that x is missing and therefore we want just the type of y coming out.


Calculating the return type: x if it exists, y if it doesn't, and the union if we don't know

So let's redefine the XorY<E> type from above so that it does the following analysis on the element type E of the array:

  • if E has a non-optional x property of type X, we should return X.
  • if E has no x property but it has a y property of type Y, we should return Y.
  • otherwise, if E has an optional x property of type X and a y property of type Y, then we should return X | Y.

That should cover all the cases (but of course there could be edge cases, so you need to test).

It's a little difficult to accurately check if the x property is optional or not; see this answer for details. Here's one way to write XorY:

type XorY<E> = E extends Param<any, infer Y> ? E['x'] extends infer X ?
  'x' extends keyof E ? {} extends Pick<E, 'x'> ? X | Y : X : Y
  : never : never

It calculates X and Y as a function of E in a weird way; Y is found with conditional type inference but for X I am indexing into E directly; that's because X will not include undefined if you use infer, but optional properties are sometimes undefined (give or take the --exactOptionalProperties compiler flag). So E['x'] is more accurate than infer when it comes to undefined.

Anyway, armed with X and Y, we return X | Y if x is a known key ('x' extends keyof E) and if it is optional ({} extends Pick<E, 'x'>). If it is known but not optional, we return X. And if it is not known, then we return Y.

Okay let's try it:

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: [string, number, undefined, string | number | undefined]
console.log(result) // ["", 1, undefined, something]

Perfect! That's about as specific as we could hope for. Again, there may be edge cases and you should test to make sure it works with your actual use cases. But I think this is as close to a solution as I can imagine for the example code as given.

Playground link to code

CodePudding user response:

There is an alternative version:

type Elem = Param;

type HandleFalsy<T extends Param> = (
  T['x'] extends undefined
  ? T['y']
  : (
    unknown extends T['x']
    ? T['y'] : T['x']
  )
)

type ArrayMap<
  Arr extends ReadonlyArray<Elem>,
  Result extends any[] = []
  > = Arr extends []
  ? Result
  : Arr extends readonly [infer H, ...infer Tail]
  ? Tail extends ReadonlyArray<Elem>
  ? H extends Elem
  ? ArrayMap<Tail, [...Result, HandleFalsy<H>]>
  : never
  : never
  : never;

type Param<X = unknown, Y = unknown> = {
  x?: X
  y: Y
}

type Json =
  | null
  | string
  | number
  | boolean
  | Array<JSON>
  | {
    [prop: string]: Json
  }


const tuple = <
  X extends Json,
  Y extends Json,
  Tuple extends Param<X, Y>,
  Arr extends Tuple[]
>(data: [...Arr]) =>
  data.map(elem => 'x' in elem ? elem.x : elem.y) as ArrayMap<Arr>

// [42, "only y"] 
const result1 = tuple([{ x: 42, y: 'str' }, { y: 'only y' }])

// [42, "only y", "100"]
const result2 = tuple([{ x: 42, y: 'str' }, { y: 'only y' }, { x: '100', y: 999999 }])

Playground

In order to better understand what's goin on in ArrayMap and HandleFalsy please take a look on js representation:

const HandleFalsy = (arg: Param) => {
  if (!arg.x) {
    return arg.y
  }
  return arg.x
}

const ArrayMap = (arr: ReadonlyArray<Elem>, result: any[] = []): Array<Param[keyof Param]> => {
  if (arr.length === 0) {
    return result
  }

  const [head, ...tail] = arr;

  return ArrayMap(tail, [...result, HandleFalsy(head)])
}

If you want to learn more about function arguments inference you can read my article

  • Related