Search code examples
typescripttypescript-types

Narrow the function return type based on the return type of a function passed as an argument


I have an issue with TypeScript 4.9.4 (in case version matters).

I expect it should be possible to alter the return type of the function processFooBar below based on the type of the function passed inside.

I need to preserve named inferred type as well.

The current best attempt is below. It infers types correctly, but only when type parameters are specified at the call site explicitly, which is not good for me:

import { expectAssignable, expectNotType, expectType } from 'tsd';

type Foo<T> = { foo: true, value: T };
type Bar = { foo: false };
type FooBar<T> = Foo<T> | Bar;

const fooInstance: Foo<number> = { foo: true, value: 42 };
const barInstance: Bar = { foo: false };

expectType<Foo<number>>( fooInstance ); // pass
expectNotType<Foo<number>>( barInstance ); // pass

expectNotType<Bar>( fooInstance ); // pass
expectType<Bar>( barInstance ); // pass

expectAssignable<FooBar<number>>( fooInstance ); // pass
expectAssignable<FooBar<number>>( barInstance ); // pass


type GetFoo<T> = () => Foo<T>;
type GetFooBar<T> = () => FooBar<T>;

const getFooInstance = () => fooInstance;
const getBarInstance = () => barInstance;
const getFooBarInstance = () => fooInstance as FooBar<number>;

expectType<GetFoo<number>>( getFooInstance ); // pass
expectNotType<GetFoo<number>>( getBarInstance ); // pass
expectNotType<GetFoo<number>>( getFooBarInstance ); // pass

expectAssignable<GetFooBar<number>>( getFooInstance ); // pass
expectAssignable<GetFooBar<number>>( getBarInstance ); // pass
expectType<GetFooBar<number>>( getFooBarInstance ); // pass


export type GetterFromFooBar<R,T1,T2 extends GetFooBar<T1>> =
  [T2] extends [GetFoo<T1>]
    ? GetFoo<R>
    : GetFooBar<R>;

// How to fix this function to properly narrow down return type to GetFoo
// when given a getter of type GetFoo,
// without requiring explicit type parameters at call site?
function processFooBar<R,T1,T2 extends GetFooBar<T1>>(
  getter: T2,
  mapper: (input: T1) => R
) {
  return (() => {
    const fooBar = getter();
    return (fooBar.foo ? ({ foo: true, value: mapper(fooBar.value) }) : ({ foo: false }) );
  }) as GetterFromFooBar<R,T1,T2>;
}

expectType<GetFoo<number>>( processFooBar<number, number, GetFoo<number>>(getFooInstance, x => x) ); // pass, explicit types
expectNotType<GetFoo<number>>( processFooBar<number, number, GetFooBar<number>>(getBarInstance, x => x) ); // pass, explicit types
expectNotType<GetFoo<number>>( processFooBar<number, number, GetFooBar<number>>(getFooBarInstance, x => x) ); // pass, explicit types

expectAssignable<GetFooBar<number>>( processFooBar<number, number, GetFoo<number>>(getFooInstance, x => x) ); // pass, explicit types
expectAssignable<GetFooBar<number>>( processFooBar<number, number, GetFooBar<number>>(getBarInstance, x => x) ); // pass, explicit types
expectType<GetFooBar<number>>( processFooBar<number, number, GetFooBar<number>>(getFooBarInstance, x => x) ); // pass, explicit types

expectType<GetFoo<number>>( processFooBar(getFooInstance, x => x) ); // Argument of type 'GetFoo<unknown>' is not assignable to parameter of type 'GetFoo<number>'.
expectNotType<GetFoo<number>>( processFooBar(getBarInstance, x => x) ); // pass, but GetFooBar<unknown> is nat expected
expectNotType<GetFoo<number>>( processFooBar(getFooBarInstance, x => x) ); // pass, but GetFooBar<unknown> is not expected

expectAssignable<GetFooBar<number>>( processFooBar(getFooInstance, x => x) ); // Argument of type 'GetFoo<unknown>' is not assignable to parameter of type 'GetFooBar<number>'.
expectAssignable<GetFooBar<number>>( processFooBar(getBarInstance, x => x) ); // Argument of type 'GetFooBar<unknown>' is not assignable to parameter of type 'GetFooBar<number>'.
expectType<GetFooBar<number>>( processFooBar(getFooBarInstance, x => x) ); // Argument of type 'GetFooBar<unknown>' is not assignable to parameter of type 'GetFooBar<number>'.
  • Is there a way to get the desired behavior while preserving meaningful return type names (GetFoo rather than () => Foo) and without requiring to specify type parameters when calling the function? (Preferably without external dependencies.)
  • or is it just not implemented yet?
  • or is it a bug?

I know it should be possible to just define function overloads in this simple example. That's actually how my actual code currently works. But it shows its limits elsewhere and I thought I'd try to introduce some smarter type inference.



Below is the simpler code I provided initially.

import { expectAssignable, expectNotType, expectType } from 'tsd';

type Foo = { foo: true, value: string };
type Bar = { foo: false };
type FooBar = Foo | Bar;

const fooInstance: Foo = { foo: true, value: '' };
const barInstance: Bar = { foo: false };

expectType<Foo>( fooInstance ); // pass
expectNotType<Foo>( barInstance ); // pass

expectNotType<Bar>( fooInstance ); // pass
expectType<Bar>( barInstance ); // pass

expectAssignable<FooBar>( fooInstance ); // pass
expectAssignable<FooBar>( barInstance ); // pass


type GetFoo = () => Foo;
type GetBar = () => Bar;
type GetFooBar = () => FooBar;

const getFooInstance = () => fooInstance;
const getBarInstance = () => barInstance;
const getFooBarInstance = () => fooInstance as Foo | Bar;

expectType<GetFoo>( getFooInstance ); // pass
expectNotType<GetFoo>( getBarInstance ); // pass
expectNotType<GetFoo>( getFooBarInstance ); // pass

expectNotType<GetBar>( getFooInstance ); // pass
expectType<GetBar>( getBarInstance ); // pass
expectNotType<GetBar>( getFooBarInstance ); // pass

expectAssignable<GetFooBar>( getFooInstance ); // pass
expectAssignable<GetFooBar>( getBarInstance ); // pass
expectType<GetFooBar>( getFooBarInstance ); // pass


export type GetterFromFooBar<T extends FooBar> = T extends Foo ? GetFoo : GetFooBar;

// How to fix this function to properly narrow down return type to GetFoo when given a getter of type GetFoo?
// Inlining GetterFromFooBar just changes the inferred type to GetFooBar.
function processFooBar(getter: GetFooBar) : GetterFromFooBar<ReturnType<typeof getter>> {
  return getter;
}

expectType<GetFoo>( processFooBar(getFooInstance) ); // Argument of type 'GetFoo | GetFooBar' is not assignable to parameter of type 'GetFoo'.
expectNotType<GetFoo>( processFooBar(getBarInstance) ); // pass, but for wrong reason
expectNotType<GetFoo>( processFooBar(getFooBarInstance) ); // pass, but for wrong reason

expectNotType<GetBar>( processFooBar(getFooInstance) ); // pass, but for wrong reason
expectType<GetBar>( processFooBar(getBarInstance) ); // Argument of type 'GetFoo | GetFooBar' is not assignable to parameter of type 'GetBar'.
expectNotType<GetBar>( processFooBar(getFooBarInstance) ); // pass, but for wrong reason

expectAssignable<GetFooBar>( processFooBar(getFooInstance) ); // pass, but for wrong reason
expectAssignable<GetFooBar>( processFooBar(getBarInstance) ); // pass, but for wrong reason
expectType<GetFooBar>( processFooBar(getFooBarInstance) ); // Parameter type GetFooBar is not identical to argument type GetFoo | GetFooBar.

@ghybs and @jcalz offered a solution that doesn't preserve the named return type and doesn't seem to be possible to modify to have return type to be derived rather than equal to input type:

function processFooBar<T extends FooBar>(getter: () => T): () => T {
    return getter;
}

@jcalz offered a working solution like this:

export type GetterFromFooBar<T extends FooBar> = 
    [T] extends [Foo] ? GetFoo : 
    [T] extends [Bar] ? GetBar :
    GetFooBar;

function processFooBar<T extends FooBar>(getter: () => T) {
    return getter as GetterFromFooBar<T>;
}

Unfortunately, I oversimplified the initial example, and this wasn't the entirety of my issue.


Solution

  • Solved it.

    No need for utility types - it is possible to infer everything into optional type parameters.

    
    function processFooBar<
      R2,
      TGetter1 extends GetFooBar<any>,
      R1 = TGetter1 extends GetFooBar<infer T> ? T : never,
      TGetter2 = TGetter1 extends GetFoo<R1> ? GetFoo<R2> : GetFooBar<R2>
    >(
      getter: TGetter1,
      mapper: (input: R1) => R2
    ) {
      return (() => {
        const fooBar = getter();
        return fooBar.foo ?
          { foo: true, value: mapper(fooBar.value as R1) }
          : { foo: false };
      }) as TGetter2;
    }
    
    const getFooInstanceWrapped = processFooBar(getFooInstance, x => x);
    const getBarInstanceWrapped = processFooBar(getBarInstance, x => x);
    const getFooBarInstanceWrapped = processFooBar(getFooBarInstance, x => x);
    
    expectType<GetFoo<number>>( getFooInstanceWrapped ); // pass
    expectNotType<GetFoo<number>>( getBarInstanceWrapped ); // pass
    expectNotType<GetFoo<number>>( getFooBarInstanceWrapped ); // pass
    
    expectAssignable<GetFooBar<number>>( getFooInstanceWrapped ); // pass
    expectType<GetFooBar<unknown>>( getBarInstanceWrapped ); // pass (Bar has no generic type attached to it so it is expected this way without extra hints)
    expectType<GetFooBar<number>>( getFooBarInstanceWrapped ); // pass
    

    Ordering of type parameters is not pretty. It is possible to bring TMapper into type parameters and derive R2 from that for a more natural ordering.

    Definition of processFooBar starts to look quite scary, but type resolution for inputs and outputs works as expected.


    Update: While this solves the question the way I formulated it, there is still no full parity with function overloading - this may collapse generic parameters earlier than needed when they are not inferable from arguments, whilst function overloading keeps generic parameters.