Skip to content

return type of function being union instead of one-of-union  #55089

Closed

Description

@y-nk

Bug Report

🔎 Search Terms

return type inference, discriminating union

🕗 Version & Regression Information

ø

⏯ Playground Link

Playground link with relevant code

💻 Code

import { assert, Equals } from 'tsafe'

// Example usage with FooBar type
type Foo = {
    foo: string;
};

type Bar = {
    bar: number;
};

type FooBar = {
    foo: Foo;
    bar: Bar;
};

type Kinded<T> = {
    [K in keyof T]: [key: K, val: T[K]]
}[keyof T]

function test<T>(...[key, val]: Kinded<T>) {
    return val
};

// @ts-expect-error -> ts complains expectedly that args[1] should be Foo
const error2 = test<FooBar>('foo', { bar: 0 });

// @ts-expect-error -> ts complains expectedly that args[1] should Bar
const error1 = test<FooBar>('bar', { foo: 'string' });

// from the errors above we know args[1] is properly typed
// but the returned value is still `Foo | Bar` :\
const foo = test<FooBar>('foo', { foo: 'string' });
const bar = test<FooBar>('bar', { bar: 0 });

// ---cut---
assert<Equals<typeof foo, Foo>>()
assert<Equals<typeof bar, Bar>>()

🙁 Actual behavior

Firstly, I do understand that the function defines arguments as (basically) declare function test<T>(...args: [keyof T, T[keyof T]]), and so the compiler thinks "test() returns args[1] which is T[keyof T] so the returned type should be T[keyof T]" (which in my example is Foo | Bar).

Where I think this stops to be logical is that type inference based on args[0] actually works great for function arguments: it successfully narrows the type of args[1] due to the discriminating union created by Kinded. We can see that this happens well thanks to the error1 and error2 constants.

🙂 Expected behavior

So, since the function only contains return val (args[1] of the tuple), and since the type of val was properly inferred in the function arguments, i guess it should be expected that return type of the function should be properly inferred rather than the static union of.

Activity

  1. y-nk commented on Jul 20, 2023

    @y-nk
    Author

    As a workaround, there's possibility to curry the function, which would end up as a workaround for partial type inference. Posting here so that people interested can find a temporary solution.

    function test<T>() => <K extends keyof T>(key: K, val: T[K]) => val

    instead of expected:

    const shouldBeFoo = test<FooBar>('foo', { foo: 'str' }) // ← type of `shouldBeFoo` is Foo | Bar

    you'll have to add () (...for no other reason than a TS one)

    const isFoo = test<FooBar>()('foo', { foo: 'str' }) // ← type of `isFoo` is Foo
  2. jp-diegidio commented on Jul 20, 2023

    @jp-diegidio

    What happens in Kinded is opaque to what happens in your test function, also notice that in test you are simply not using the value of key, indeed, if I've got your intent correctly, I think you are just not using the right parameters. Here is a piece of code (with some renaming trying to make intents more explicit) that seems to do the job, with two major modifications re your code: 1) the type parameter I am actually using, tied to a specific key, not to the whole "type map", and 2) I am not using spread arguments, which complicates things significantly: returning an array of results just brings us back to type unions, maybe returning an object of type Partial<TypeMap> would do the trick, but I'll leave solving this as a next step and only if you confirm and clarify the requirement, otherwise I simply don't see the point, or maybe it shouldn't be called call test (which maybe is not the right name anyway, this is more of a checked coercion)...:

    type Foo = {
        foo: string;
    };
    
    type Bar = {
        bar: number;
    };
    
    type TypeMap = {
        foo: Foo;
        bar: Bar;
    };
    
    function test<K extends keyof TypeMap>(key: K, val: TypeMap[typeof key]): TypeMap[K] {
        return val;
    };
    
    // @ts-expect-error : Argument of type '{ bar: number; }'
    // is not assignable to parameter of type 'Foo'
    const errorFoo = test('foo', { bar: 0 });
    // @ts-expect-error : Argument of type '{ foo: string; }'
    // is not assignable to parameter of type 'Bar'
    const errorBar = test('bar', { foo: 'string' });
    
    // the returned value here is `Foo` as expected
    const foo = test('foo', { foo: 'string' });
    // the returned value here is `Bar` as expected
    const bar = test('bar', { bar: 0 });

    Link to code in playground

  3. y-nk commented on Jul 20, 2023

    @y-nk
    Author

    Julio Di Egidio (@jp-diegidio) thanks for taking the time to look at my problem 🙏

    there's something not on par with my actual issue in your proposal, is that TypeMap is hardcoded into test. What i was looking for is to do exactly the same but with a "pluggable" type map.

    you can see here:

    const error2 = test<FooBar>('foo', { bar: 0 });

    that i'm purposely passing the type into the function's generic. The intent behind is that this function should be reusable for anything extending Record<string, any>.

  4. jp-diegidio commented on Jul 20, 2023

    @jp-diegidio

    Julien Barbay (@y-nk) You are welcome and thank you too, I myself am learning TS...

    I have played with it a little bit more and below is the best I could get (so far), still there is (IME) something "smelly" with this pattern, anyway maybe you/others can take it from here.

    type Foo = {
        foo: string;
    };
    
    type Bar = {
        bar: number;
    };
    
    type Keyed<M> = Record<keyof M, M[keyof M]>;
    
    type TypeMap = {
        foo: Foo;
        bar: Bar;
    };
    
    // without the default for param `T`, cannot be called with
    // only one type argument instead of getting a union:
    function test<M extends Keyed<M>, T extends M[keyof M] = M[keyof M]>(val: T): T {
        return val;
    };
    
    // @ts-expect-error : Argument of type '{ bar: number; }'
    // is not assignable to parameter of type 'never'
    const errorFoo = test({ bar: 0 });
    // @ts-expect-error : Argument of type '{ foo: string; }'
    // is not assignable to parameter of type 'never'
    const errorBar = test({ foo: 'string' });
    
    // returns the union `Foo | Bar`
    const unionFoo = test<TypeMap>({ foo: 'string' });
    // returns the union `Foo | Bar`
    const unionBar = test<TypeMap>({ bar: 0 });
    
    // returns exactly `Foo` (but, isn't this just a
    // convoluted way to say "I want Foo" to begin with?)
    const foo = test<TypeMap, Foo>({ foo: 'string' });
    // returns exactly `Bar` (but, isn't this just a
    // convoluted way to say "I want Bar" to begin with?)
    const bar = test<TypeMap, Bar>({ bar: 0 });

    Link to code in playground

  5. jp-diegidio commented on Jul 20, 2023

    @jp-diegidio

    This improves on my previous code and might even be it (up to you to say), surely it's the nearest I can get that still makes sense to me. I have shuffled things around a bit, so if one wants it's still parametrizable on a "type map", though at a quite different level here:

    function check<T = never, U extends T = T>(val: U): U {
        return val;
    };
    
    type Foo = {
        foo: string;
    };
    
    type Bar = {
        bar: number;
    };
    
    type TypeMap = {
        foo: Foo;
        bar: Bar;
    };
    
    // @ts-expect-error : Argument of type '{ bar: number; }'
    // is not assignable to parameter of type 'never'
    const errorFoo = check({ bar: 0 });
    // @ts-expect-error : Argument of type '{ foo: string; }'
    // is not assignable to parameter of type 'never'
    const errorBar = check({ foo: '' });
    
    // returns `Foo` as expected
    const foo = check<TypeMap['foo']>({ foo: '' });
    // returns `Bar` as expected
    const bar = check<TypeMap['bar']>({ bar: 0 });
    
    // returns `Foo` as expected
    // (indeed, `TypeMap['foo']` is equivalent to just `Foo`)
    const foo2 = check<Foo>({ foo: '' });
    // returns `Bar` as expected
    // (indeed, `TypeMap['bar']` is equivalent to just `Bar`)
    const bar2 = check<Bar>({ bar: 0 });

    Link to code in playground

  6. jp-diegidio commented on Jul 20, 2023

    @jp-diegidio

    P.S. Keeping in mind that a "checked coercion" is just a simple example of a generic function of the kind I understand you are asking for, but is per se quite pointless, since a const foo: Foo = something or indeed a const foo: TypeMap['foo'] = something already achieve that result.

  7. RyanCavanaugh commented on Jul 20, 2023

    @RyanCavanaugh
    Member

    I don't understand what the alleged defect is. You have type arguments in all these positions, so of course f<T>(a) and f<T>(b) aren't going to return different types -- you've fixed them both to T

  8. fatcerberus commented on Jul 20, 2023

    @fatcerberus

    If I’m reading this right, OP seems, in general, to expect <T, U>(x: T | U) => x to return T or U only (instead of T | U) depending on which was passed in, but that’s not how return-type inference works

  9. y-nk commented on Jul 26, 2023

    @y-nk
    Author

    Ryan Cavanaugh (@RyanCavanaugh) Bruce Pascoe (@fatcerberus) yes and no.

    I do know that <T, U>(x: T | U) => x will return T | U but here I think that the case is different since it's not a plain union but rather a discriminated union based on one of the argument of the function.

    The function can be seen as f<T, K extends keyof T>(a: K, b: T[K]) => b, but in order to avoid having to pass manually K, I built the function's arguments as a tuple which acts as a discriminated union like this:

    type Kinded<T> = {
      [K in keyof T]: [key: K, val: T[K]]
    }[keyof T]
    
    image

    ...which allows to change f<T, K extends keyof T>(a: K, b: T[K]) => b to become f<T>(...args: Kinded<T>) => args[1] with the expectation that, due to the discriminated union built for args, then args[1] should not be T[keyof T] but a solid T[K].

    image

    I also do understand that, without args[0] the computed returned type is Kinded<T>[1] which is indeed a union of all T[K] but how come that at use of the function, the returned type is not inferred properly?

  10. y-nk commented on Jul 26, 2023

    @y-nk
    Author

    on the side i've written in the ticket description that i do understand what you tell i don't.

    Firstly, I do understand that the function defines arguments as (basically) declare function test(...args: [keyof T, T[keyof T]]), and so the compiler thinks "test() returns args[1] which is T[keyof T] so the returned type should be T[keyof T]" (which in my example is Foo | Bar).

    where i stopped understanding this is when a discriminating union comes into play.

  11. added
    QuestionAn issue which isn't directly actionable in code
    and removed
    Needs More InfoThe issue still hasn't been fully clarified
    on Jul 26, 2023
  12. RyanCavanaugh commented on Jul 26, 2023

    @RyanCavanaugh
    Member

    Please don't post screenshots of code.

    It seems like this is more of a question than a bug report; nothing outlined here is indicative of a defect.

  13. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions