asemahle
asemahle

Reputation: 20795

TypeScript - Return type based on input type

I have a union type that can be narrowed based on a kind property. I would like to create a function whose input is one of those kinds ("Foo" or "Bar"), and whose output is a function that accepts the matching type as input (FooType or BarType).

I tried implementing such a function (getHandler) as follows:

type FooType = { kind: "Foo", fooMsg: string }
type BarType = { kind: "Bar", barMsg: string }

function getHandler(kind: "Foo" | "Bar") {
  switch (kind) {
    case "Foo":
      return { handle: (foo: FooType) => console.log(foo.fooMsg) };
    case "Bar":
      return { handle: (bar: BarType) => console.log(bar.barMsg) };
  }
}

const fooBar: FooType | BarType = getFooOrBar(); // return type is 'FooType | BarType'
getHandler(fooBar.kind).handle(fooBar);

But this throws an error on the call to handle(fooBar):

TS2345: Argument of type 'FooType | BarType' is not assignable to parameter of type 'never'.

I understand the error, but I'd like to know if there's a way to communicate to TypeScript that this will not fail (without resorting to using any or //@ts-ignore).

Specifically, because getHandler was called using fooBar's kind, I know that the handle function will be able to operate on fooBar. Is there a way express that fact in TypeScript?

Upvotes: 1

Views: 98

Answers (1)

Alberto Chiesa
Alberto Chiesa

Reputation: 7350

I got rid of the {handle: ...} function wrapper, but it should work in any case. The important bit is in the signature of getHandler.

In particular, in the usage of mapped types to build the Handlers<...> generic type:

type Handlers<TypesWithKind extends { kind: string }> = {
    [T in TypesWithKind as T["kind"]]: (obj: T) => void;
}

I could not figure out a way to avoid a (albeit safe) cast in the return statements in the getHandler method, but I think it should be ok.

Link to TS Playground

type FooType = { kind: "Foo", fooMsg: string }
type BarType = { kind: "Bar", barMsg: string }

type FooBarType = FooType | BarType;

// see: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
type Handlers<TypesWithKind extends { kind: string }> = {
    [T in TypesWithKind as T["kind"]]: (obj: T) => void;
}

type FooBarHandlers = Handlers<FooType | BarType>;

function getHandler<T extends (FooType | BarType)['kind']>(kind: T): FooBarHandlers[typeof kind] {
    switch (kind) {
        case "Foo":
            // here TS is confused about the type of kind. It is still recognized as 'Foo' | 'Bar' instead of simply Foo.
            // I bail out with a cast, which is kind of bummer, but I suppose we are more interested in the calling part
            return ((foo: FooType) => { console.log(foo.fooMsg); }) as FooBarHandlers[typeof kind];
        case "Bar":
            return ((bar: BarType) => {console.log(bar.barMsg); }) as FooBarHandlers[typeof kind];
        default:
            throw new Error('UnexpectedValue!');
    }
}

const aFoo: FooType | BarType = {
    kind: "Foo", fooMsg: 'Message'
};
const fooHandler = getHandler(aFoo.kind)
fooHandler(aFoo);

const aBar: BarType = {
    kind: "Bar", barMsg: 'Message'
};
const barHandler = getHandler(aBar.kind);
barHandler(aBar);

// you could also simply use an object:
const handlers: FooBarHandlers = {
    Foo: (foo: FooType) => { console.log(foo.fooMsg); },
    Bar: (bar: BarType) => {console.log(bar.barMsg); }
};
handlers[aFoo.kind](aFoo);

Upvotes: 1

Related Questions