I have a class method which accepts a single argument as a string and returns an object which has the matching type
property. This method is used to narrow a discriminated union type down, and guarantees that the returned object will always be of the particular narrowed type which has the provided type
discriminate value.
I'm trying to provide a type signature for this method that will correctly narrow the type down from a generic param, but nothing I try narrows it down from the discriminated union without the user explicitly providing the type it should be narrowed down to. That works, but is annoying and feels quite redundant.
Hopefully this minimum reproduction makes it clear:
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = ExampleAction | AnotherAction;
declare class Example<T extends Action> {
// THIS IS THE METHOD IN QUESTION
doSomething<R extends T>(key: R['type']): R;
}
const items = new Example<MyActions>();
// result is guaranteed to be an ExampleAction
// but it is not inferred as such
const result1 = items.doSomething('Example');
// ts: Property 'example' does not exist on type 'AnotherAction'
console.log(result1.example);
/**
* If the dev provides the type more explicitly it narrows it
* but I'm hoping it can be inferred instead
*/
// this works, but is not ideal
const result2 = items.doSomething<ExampleAction>('Example');
// this also works, but is not ideal
const result3: ExampleAction = items.doSomething('Example');
I also tried getting clever, attempting to build up a "mapped type" dynamically--which is a fairly new feature in TS.
declare class Example2<T extends Action> {
doSomething<R extends T['type'], TypeMap extends { [K in T['type']]: T }>(key: R): TypeMap[R];
}
This suffers from the same outcome: it doesn't narrow the type because in the type map { [K in T['type']]: T }
the value for each computed property, T
, is not for each property of the K in
iteration but is instead just the same MyActions
union. If I require the user provide a predefined mapped type I can use, that would work but this is not an option as in practice it would be a very poor developer experience. (the unions are huge)
This use case might seem weird. I tried to distill my issue into a more consumable form, but my use case is actually regarding Observables. If you're familiar with them, I'm trying to more accurately type the ofType
operator provided by redux-observable. It is basically a shorthand for a filter()
on the type
property.
This is actually super similar to how Observable#filter
and Array#filter
also narrow the types, but TS seems to figure that out because the predicate callbacks have the value is S
return value. It's not clear how I could adapt something similar here.
Like many good solutions in programming, you achieve this by adding a layer of indirection.
Specifically, what we can do here is add a table between action tags (i.e. "Example"
and "Another"
) and their respective payloads.
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
then what we can do is create a helper type that tags each payload with a specific property that maps to each action tag:
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
Which we'll use to create a table between the action types and the full action objects themselves:
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
This was an easier (albeit way less clear) way of writing:
type ActionTable = {
"Example": { type: "Example" } & { example: true },
"Another": { type: "Another" } & { another: true },
}
Now we can create convenient names for each of out actions:
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
And we can either create a union by writing
type MyActions = ExampleAction | AnotherAction;
or we can spare ourselves from updating the union each time we add a new action by writing
type Unionize<T> = T[keyof T];
type MyActions = Unionize<ActionTable>;
Finally we can move on to the class you had. Instead of parameterizing on the actions, we'll parameterize on an action table instead.
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
That's probably the part that will make the most sense - Example
basically just maps the inputs of your table to its outputs.
In all, here's the code.
/**
* Adds a property of a certain name and maps it to each property's key.
* For example,
*
* ```
* type ActionPayloadTable = {
* "Hello": { foo: true },
* "World": { bar: true },
* }
*
* type Foo = TagWithKey<"greeting", ActionPayloadTable>;
* ```
*
* is more or less equivalent to
*
* ```
* type Foo = {
* "Hello": { greeting: "Hello", foo: true },
* "World": { greeting: "World", bar: true },
* }
* ```
*/
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
type Unionize<T> = T[keyof T];
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
type MyActions = Unionize<ActionTable>
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
const items = new Example<ActionTable>();
const result1 = items.doSomething("Example");
console.log(result1.example);
As of TypeScript 2.8, you can accomplish this via conditional types.
// Narrows a Union type base on N
// e.g. NarrowAction<MyActions, 'Example'> would produce ExampleAction
type NarrowAction<T, N> = T extends { type: N } ? T : never;
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions =
| ExampleAction
| AnotherAction;
declare class Example<T extends Action> {
doSomething<K extends T['type']>(key: K): NarrowAction<T, K>
}
const items = new Example<MyActions>();
// Inferred ExampleAction works
const result1 = items.doSomething('Example');
NOTE: Credit to @jcalz for the idea of the NarrowAction type from this answer https://stackoverflow.com/a/50125960/20489
This requires a change in TypeScript to work exactly as asked in the question.
If the classes can be grouped as properties of a single object then the accepted answer can help too. I love the Unionize<T>
trick in there.
To explain the actual problem, let me narrow down your example to this:
class RedShape {
color: 'Red'
}
class BlueShape {
color: 'Blue'
}
type Shapes = RedShape | BlueShape;
type AmIRed = Shapes & { color: 'Red' };
/* Equals to
type AmIRed = (RedShape & {
color: "Red";
}) | (BlueShape & {
color: "Red";
})
*/
/* Notice the last part in before:
(BlueShape & {
color: "Red";
})
*/
// Let's investigate:
type Whaaat = (BlueShape & {
color: "Red";
});
type WhaaatColor = Whaaat['color'];
/* Same as:
type WhaaatColor = "Blue" & "Red"
*/
// And this is the problem.
Another thing you could do is pass the actual class to the function. Here's a crazy example:
declare function filterShape<
TShapes,
TShape extends Partial<TShapes>
>(shapes: TShapes[], cl: new (...any) => TShape): TShape;
// Doesn't run because the function is not implemented, but helps confirm the type
const amIRed = filterShape(new Array<Shapes>(), RedShape);
type isItRed = typeof amIRed;
/* Same as:
type isItRed = RedShape
*/
The problem here is you cannot get the value of color
. You can RedShape.prototype.color
, but this will always be undefined, because the value is only applied in constructor. RedShape
is compiled to:
var RedShape = /** @class */ (function () {
function RedShape() {
}
return RedShape;
}());
And even if you do:
class RedShape {
color: 'Red' = 'Red';
}
That compiles to:
var RedShape = /** @class */ (function () {
function RedShape() {
this.color = 'Red';
}
return RedShape;
}());
And in your real example constructors might have multiple parameters, etc, so an instantiation might not be possible too. Not to mention it doesn't work for interfaces too.
You might have to revert to silly way like:
class Action1 { type: '1' }
class Action2 { type: '2' }
type Actions = Action1 | Action2;
declare function ofType<TActions extends { type: string },
TAction extends TActions>(
actions: TActions[],
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = ofType(new Array<Actions>(), Action1, '1');
/* Same as if
var one: Action1 = ...
*/
Or in your doSomething
wording:
declare function doSomething<TAction extends { type: string }>(
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = doSomething(Action1, '1');
/* Same as if
const one : Action1 = ...
*/
As mentioned in a comment on the other answer, there is an issue in the TypeScript for fixing the inference issue already. I wrote a comment linking back to this answer's explanation, and providing a higher level example of the problem here.
Unfortunately, you cannot achieve this behavior using union type (ie type MyActions = ExampleAction | AnotherAction;
).
However, your solution is great. You just have to use this way to define the type you need.
const result2 = items.doSomething<ExampleAction>('Example');
Although you don't like it, it seems pretty legit way to do what you want.
A little more verbose on the setup but we can achieve your desired API with type lookups:
interface Action {
type: string;
}
interface Actions {
[key: string]: Action;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = {
Another: AnotherAction;
Example: ExampleAction;
};
declare class Example<T extends Actions> {
doSomething<K extends keyof T, U>(key: K): T[K];
}
const items = new Example<MyActions>();
const result1 = items.doSomething('Example');
console.log(result1.example);
来源:https://stackoverflow.com/questions/46312206/narrowing-a-return-type-from-a-generic-discriminated-union-in-typescript