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 di
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
Unfortunately, you cannot achieve this behavior using union type (ie type MyActions = ExampleAction | AnotherAction;
).
If we have a value that has a union type, we can only access members that are common to all types in the union.
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.
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.
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);
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);