Can't make TypeScript generics chain work

Viewed 327

I'm building a function that accepts a functions chain, where each function transforms results from the previous one. In the given example, _context should be equal { hello: 'world', something: 'else' }:

function withWorld<Context extends {}>(context: Context) {
  return { ...context, hello: 'world' }
}

function withSomething<Context extends {}>(context: Context) {
  return { ...context, something: 'else' }
}

test([withWorld, withSomething], (_context) => {})

However, when I try to describe this behavior with generics, I stumble upon an error:

No overload matches this call:
  Types of parameters 'contextSomething' and 'contextB' are incompatible.
    Type 'unknown' is not assignable to type '{}'.

Here's the code (TypeScript playground):

export interface TestFunction {
  <A>(
    conditions: [(contextSolo: {}) => A],
    body: (contextBodySolo: A) => any
  ): any

  <A, B>(
    conditions: [(contextA: {}) => A, (contextB: A) => B],
    body: (contextBody: B) => void
  ): any
}

const test: TestFunction = () => void 0

function withWorld<Context extends {}>(contextWithWorld: Context) {
  return { ...context, hello: 'world' }
}

function withSomething<Context extends {}>(contextSomething: Context) {
  return { ...context, something: 'else' }
}

test([withSomething], (_context) => {})
test([withWorld], (_context) => {})
test([withWorld, withSomething], (_context) => {})

When there's a single function in the array, TypeScript infers the types alright, even when the example is more complicated and has an initial state (TypeScript playground).

Complete error:

No overload matches this call.
  Overload 1 of 2, '(conditions: [(contextSolo: {}) => any], body: (contextBodySolo: any) => any): any', gave the following error.
    Argument of type '[<Context extends {}>(contextWithWorld: Context) => any, <Context extends {}>(contextSomething: Context) => any]' is not assignable to parameter of type '[(contextSolo: {}) => any]'.
      Source has 2 element(s) but target allows only 1.
  Overload 2 of 2, '(conditions: [(contextA: {}) => unknown, (contextB: unknown) => any], body: (contextBody: any) => void): any', gave the following error.
    Type '<Context extends {}>(contextSomething: Context) => any' is not assignable to type '(contextB: unknown) => any'.
      Types of parameters 'contextSomething' and 'contextB' are incompatible.
        Type 'unknown' is not assignable to type '{}'.(2769)
2 Answers

Here you have working example:

export interface TestFunction<Ctx> {
  <A>(
    conditions: [(context: Ctx) => A],
    body: (context: A) => any
  ): any

  <A, B>(
    conditions: [(context: Ctx) => A & Ctx, (context: A & Ctx) => B],
    body: (context: B & Ctx) => void
  ): any
}

const test: TestFunction<{ hello: 'world' }> = () => void 0

function withWorld<Context extends { hello: 'world' }>(context: Context) {
  return { ...context, world: 'hello' }
}

function withSomething<Context extends {}>(context: Context) {
  return { ...context, something: 'else' }
}

test([withSomething], (_context) => void 0)
test([withWorld], (_context) => void 0)
test([withWorld, withSomething], (_context) => void 0)

I explicitly added Ctx as intersection to each argument

UPDATE

Here you have generic solution:

type Fn = (...args: any[]) => any
type MapObject<T extends Fn> = ReturnType<T>
type Elem = Fn

type Mapper<
  Arr extends ReadonlyArray<Elem>,
  Result extends Record<string, any> = {}
  > = Arr extends []
  ? Result
  : Arr extends [infer H]
  ? H extends Elem
  ? Result & MapObject<H>
  : never
  : Arr extends readonly [infer H, ...infer Tail]
  ? Tail extends ReadonlyArray<Elem>
  ? H extends Elem
  ? Mapper<Tail, Result & MapObject<H>>
  : never
  : never
  : never


type Foo = { foo: 'foo' }
type Bar = { bar: 'bar' }
type Baz = { baz: 'baz' }

type Result = Mapper<[(arg: number) => Foo, (arg: Foo) => Bar, (arg: Bar) => Baz]> // Foo & Bar & Baz

export interface TestFunction<Ctx> {
  <A>(
    conditions: [(context: Ctx) => A],
    body: (context: A) => any
  ): any

  <A, B, C extends ReadonlyArray<any>>(
    conditions: C,
    body: (context: Mapper<C, Ctx>) => void
  ): any
}

const test: TestFunction<{ hello: 'world' }> = () => void 0

function withWorld<Context extends { hello: 'world' }>(context: Context) {
  return { ...context, world: 'hello' }
}

function withSomething<Context extends {}>(context: Context) {
  return { ...context, something: 'else' }
}

function withSomethingElse<Context extends {}>(context: Context) {
  return { ...context, somethingElse: 'smth else' }
}

test([withSomething], (_context) => void 0)
test([withWorld], (_context) => void 0)
test([withWorld, withSomething, withSomethingElse] as const, (_context) => void 0)

Here you can find other examples wich might be interesting for you

Mutable and immutable arrays

const mutable1 = [1, 2] // number[]

const mutable2 = [{ age: 1 }, { name: 'John' }]

type MutableLength = (typeof mutable2)['length'] // number, we don't know the length

// const mutable2: ({
//     age: number;
//     name?: undefined;
// } | {
//     name: string;
//     age?: undefined;
// })[]

// As you see, if you want to operate on mutable array, TS will just make a union type from all array customElements

const immutable =  [{ age: 1 }, { name: 'John' }] as const

type ImmutableLength = (typeof immutable)['length'] // length is 2, we know exactly the length of array and the type of all elements

// Here, TS is able to say that your array has exact two elements

UPDATE, I hope the last :D

My bad, I thought it is impossible to make it with mutable arrays, but I just should have to take a look on the problem from different angle.

Here is working solution:

type Fn = (...args: any[]) => any

// credits goes https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

export interface TestFunction<Ctx> {
  <A>(
    conditions: [(context: Ctx) => A],
    body: (context: A) => any
  ): any

  <C extends Array<Fn>>(
    conditions: C,
    body: (context: UnionToIntersection<ReturnType<C[number]>>) => void
  ): any
}

const test: TestFunction<{ hello: 'world' }> = () => void 0


function withWorld<Context extends { hello: 'world' }>(context: Context) {
  return { ...context, world: 'hello' }
}

function withSomething<Context extends {}>(context: Context) {
  return { ...context, something: 'else' }
}

function withSomethingElse<Context extends {}>(context: Context) {
  return { ...context, somethingElse: 'smth else' }
}

test([withSomething], (_context) => void 0)
test([withWorld], (_context) => void 0)
test([withWorld, withSomething, withSomethingElse], (_context) => void 0)

A Tuple is an array with individual types per index. Therefore the length is fixed.

An array by default has one type, the same for all elements, and the length is not fixed.

Tuples have to be manually coerced. That's hard to see because your example is complex. Doing so made the errors go away in Typescript Playground

type  Tuple<A,B>=[(contextA: {}) => A, (contextB: A) => B]
export interface TestFunction {
  <A>(
    conditions: [(contextSolo: {}) => A],
    body: (contextBodySolo: A) => any
  ): any

  <A, B>(
    conditions: Tuple<A,B>,
    body: (contextBody: B) => void
  ): any
}

const test: TestFunction = () => void 0

function withWorld<Context extends {}>(context: Context) {
  return { ...context, hello: 'world' }
}

function withSomething<Context extends {}>(context: Context) {
  return { ...context, something: 'else' }
}

test([withSomething], (_context) => {})
test([withWorld], (_context) => {})
test([withWorld, withSomething] as Tuple<typeof withWorld, typeof withSomething>, (_context) => {})

The manual coercion is at the end where

[withWorld, withSomething]

was changed to

[withWorld, withSomething] as Tuple<typeof withWorld, typeof withSomething>

Having to manually coerce tuples is common in TS because the default is is to create an array with the logical OR of all the elements.

To write is as a type:

type Default=(withWorld|withSomething)[]

Type this into the Typescript playground

let a=[1,'1']

Place your cursor over the 'a' and it shows

let a:(string|number)[]

and NOT

let a:[string,number]
Related