Search code examples
typescriptmapped-types

How to map & index into tuple types that is generically spread in TypeScript?


TS 4.0 allows spreading tuple types and labelled tuple types.

I'm attempting to use both features to create a sort of with-context function or bracketing pattern.

Here is my attempt:

type Resource<T = void> = () => Promise<[
  release: () => Promise<void>,
  resource: T
]>;

async function withF<
  Resources extends Array<Resource<unknown>>,
  Result
>(
  resources: Resources,
  f: (...resources: [...Resources]) => Promise<Result>
): Promise<Result> {
  const releases = [];
  const items = [];
  try {
    for (const resource of resources) {
      const [release, item] = await resource();
      releases.push(release);
      items.push(item);
    }
    return await f(...items);
  } finally {
    releases.reverse();
    for (const release of releases) {
      await release();
    }
  }
}

The idea is that you can use it like this:

let count: number = 0;
await withF(
  [
    async () => {
      ++count;
      return [async () => { --count; }, count];
    }
  ],
  async (c: number) => {
    return c;
  }
);

The problem is that the types don't match because in my:

  f: (...resources: [...Resources]) => Promise<Result>

The Resources extends Array<Resource<unknown>>, and I want to say that f takes a spread of the second element for each return type promise of Resources.

First challenge is how to do a mapping type into Resources. It seems it should be possible with https://devblogs.microsoft.com/typescript/announcing-typescript-3-1/#mappable-tuple-and-array-types.

The second step is to apply the indexing option. Which should work in the mapping type as well. But again I'm not sure how to do this.

Ideally we want some sort of type constructor that does it:

  f: (...resources: [...TC<Resources>]) => Promise<Result>

Where TC is a special type constructor that maps Resources to the 2nd element of each return type and still preserves the tuple length & order.


Further attempts for mapping into a tuple of functions:

type Functions = ((...args: any) => unknown)[];
type FunctionReturns<T extends [...Functions]> = { [K in keyof T]: ReturnType<T[K]> };

const fs: Functions = [() => 1, () => 'abc'];

type FsReturns = FunctionReturns<typeof fs>;

For whatever reason, even though basic ability to map into tuple types work, the ReturnType here still complains even though we've said that T extends an array of functions. It seems that ReturnType doesn't seem to work when attempting to map into tuple types.


Solution

  • The mapping of Resources to their types (as you also found) can be done using something similar to this answer, with the addendum that using a constraint of T extends [U] | U[] will make the compiler infer a tuple of U for T instead of an array of U.

    Once that is in place we have the issue that typescript is unsure that the result of the mapped type will necessarily be an array. We can get around this by adding an intersection with unknown[]

    type ReturnsOfResources<T extends Resource<any>[]> = {
      [P in keyof T] : T[P] extends Resource<infer R> ? R: never
    }
    
    async function withF<
      Resources extends [Resource<unknown>] | Array<Resource<unknown>>,
      Result
    >(
      resources: Resources,
      f: (...resources: ReturnsOfResources<Resources> & unknown[]) => Promise<Result>
    ): Promise<Result> {
      const releases = [];
      const items = [];
      try {
        for (const resource of resources) {
          const [release, item] = await resource();
          releases.push(release);
          items.push(item);
        }
        return await f(...items as ReturnsOfResources<Resources>);
      } finally {
        releases.reverse();
        for (const release of releases) {
          await release();
        }
      }
    }
    

    Playground Link

    If you want to get a version working with as const assertions you will have to change the code to deal with the readonly tuples generated by as const, also when creating the tuple, you will need assertions on the container tuple as well as the tuples returned from resource creating function.

    
    type ReturnsOfResources<T extends readonly Resource<any>[]> = {
      -readonly [P in keyof T]: T[P] extends Resource<infer R> ? R : never
    }
    
    async function withF<
      Resources extends readonly [Resource<unknown>] | readonly Resource<unknown>[],
      Result
    >(
      resources: Resources,
      f: (...resources: ReturnsOfResources<Resources> & unknown[]) => Promise<Result>
    ): Promise<Result> {
      const releases = [];
      const items = [];
      try {
        for (const resource of resources) {
          const [release, item] = await resource();
          releases.push(release);
          items.push(item);
        }
        return await f(...items as any);
      } finally {
        releases.reverse();
        for (const release of releases) {
          await release();
        }
      }
    }
    
    async function x() {
      let count: number = 0;
    
      const resources = [
        async () => {
          ++count;
          return [async () => { --count; }, count] as const;
        },
        async () => {
          return [async () => { }, 'count']  as const;
        }
      ] as const
    
      await withF(
        resources,
        async (c, cs) => {
          return c;
        }
      );
    }
    

    Playground Link