3

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)
Sasha Koss
  • 15,129
  • 4
  • 20
  • 27

2 Answers2

3

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)
  • Thank you for an option, but that's not ideal as this approach won't work if some of the functions replace the original context as the type explicitly expect intersection, ie: `return { world: 'hello' }`. I am also curious how this could be scaled for 3 and more items. – Sasha Koss Feb 08 '21 at 08:46
  • 1
    I'm working on generic solution, this is the first question I asked myself ;) – captain-yossarian from Ukraine Feb 08 '21 at 08:59
  • Appreciate it! – Sasha Koss Feb 08 '21 at 09:05
  • Thank you a lot for the answer and examples! It surprises me that TypeScript doesn't provide a simpler solution to the problem. I hope that I can find an API that doesn't require users to write `as const`. – Sasha Koss Feb 08 '21 at 11:09
  • I added an update regarding the mutable and immutable arrays – captain-yossarian from Ukraine Feb 08 '21 at 11:19
  • Please keep in mind, TS is about static typing, not dynamic – captain-yossarian from Ukraine Feb 08 '21 at 11:21
  • @SashaKoss I made last update, I hope this will work for you – captain-yossarian from Ukraine Feb 08 '21 at 11:53
  • An amazing, that's the closest option so far. I don't want to appear not grateful and picky, but there's still one issue that I hope to solve: when you omit `...context`, the final result still contains all the data from previous functions. This is OK as I can always assign the previous value when processing the functions (which is arguable a nicer API even though it's implicit). However, if I decided to alter a field from previous functions (for example, set it to null), the final results end up never. – Sasha Koss Feb 09 '21 at 04:01
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/228453/discussion-between-captain-yossarian-and-sasha-koss). – captain-yossarian from Ukraine Feb 09 '21 at 07:17
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]
Craig Hicks
  • 2,199
  • 20
  • 35
  • Sadly, the solution doesn't properly infer `_context`. Also, having to explicitly coerce produces unpleasant API. – Sasha Koss Feb 08 '21 at 08:52
  • True, it doesn't know it to pass along the partial result without explicitly doing so. Sometimes being straightforward is best. (Maybe always). – Craig Hicks Feb 08 '21 at 09:14