Lux
Lux

Reputation: 18240

How to type a return type depending input parameter

I have a function that receives an options argument with a kind attribute. The possible values for kind is a small set of values. So its basically an enum.

depending on the kind the function should have a different return value. While all possible return values extend from some common base type.

I can achieve what I want with overloads, but then the function itself is not typed very well:

function test(options: Scenarios['bar']['options']): Scenarios['bar']['ret'];
function test(options: Scenarios['foo']['options']): Scenarios['foo']['ret'];
function test(options: any): any {
  ...
};

Is there a nice way to type this using generics? It would be perfect it a if(options.kind === 'foo') { return ... } would also correctly enforce the correct return type.

Thats what I tried but it isnt working.

type Base {
  a: string;
}

type Foo {
  b: string;
}

type Bar {
  c: string;
}

interface Scenarios {
  foo: { options: { kind: 'foo', input: string }, ret: Foo },
  bar: { options: { kind: 'bar' }, ret: Bar },
}

function test<S extends keyof Scenarios, O extends Scenarios[S]['options'], R extends Scenarios[S]['ret']>(options: O): R {
  const out: Partial<R> = {
    a: 'one',
  };
  if(options.kind === 'foo') {
    out.b = options.input;
  }
  if(options.kind === 'bar') {
    out.c = "whatever"
  }

  return out;
}

Here neither O nor R seems not to be correctly typed. I get multiple errors:

Upvotes: 5

Views: 886

Answers (1)

Mu-Tsun Tsai
Mu-Tsun Tsai

Reputation: 2534

I'm not really an expert on this subject, but after playing awhile with your request, I realize a few things:

  1. TypeScript can only discriminate among union types; it cannot discriminate generics.
  2. The said discrimination does not propagate upwards; that is, TypeScript won't automatically discriminate parent object based on the discrimination of child objects.

These being said, here's a solution I came up with:

type Base = { // I'm assuming this is the base type for Foo and Bar
    a: string;
}

interface Foo extends Base { // so I shall modify here a bit
    b: string;
}

interface Bar extends Base { // and here of course
    c: string;
}

interface Scenarios {
    // Now I put the return type inside the option, to make the discrimination work
    // Of course, I need to make 'ret' optional, or your input won't make sense
    foo: { kind: 'foo', input: string, ret?: Foo },
    bar: { kind: 'bar', ret?: Bar },
}

// Notice the use of 'Required' here; that brings the actual type of 'ret' back
function test<X extends keyof Scenarios>(options: Scenarios[X]): Required<Scenarios[X]>['ret'] {
    let data = options as Scenarios[keyof Scenarios]; // create a union type
    data.ret = { a: 'one' } as Scenarios[keyof Scenarios]['ret']; // type assertion

    if (data.kind === 'foo') {
        data.ret!.b = data.input; // finally the discrimination works!
    }
    if (data.kind === 'bar') {
        data.ret!.c = "whatever"
    }
    return data.ret!;
}

Ok, so far so good, unfortunately I still can't make TypeScript to infer the generic argument automatically. Say if I run:

var result = test({ kind: 'foo', input: "aas" }); // oops

Then TypeScript still cannot figure out that result is of type Foo. But, of course, this kind of auto-inference is of very low practical value, since even if it works, it works only when you literally type the word 'foo' in the argument of your function, and if you can do that, what stops you from typing the generic argument?

You may try these code in this Playground Link

Update:

I just found the solution to my last problem:

function test<X extends keyof Scenarios>(options: { kind: X } & Scenarios[X]): Required<Scenarios[X]>['ret'] {
    ....
}

var result = test({ kind: 'foo', input: "aas" }); // It works!

The trick is to add { kind: X } to the parameter declaration.

See this Playground Link.

Upvotes: 2

Related Questions