Reputation: 20795
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
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