web-dev-qa-db-ja.com

TypeScriptの一般的な識別された共用体から戻り型を絞り込む

単一の引数を文字列として受け入れ、対応するtypeプロパティを持つオブジェクトを返すクラスメソッドがあります。このメソッドは、判別された共用体タイプを絞り込むために使用され、返されるオブジェクトが常に、提供されたtype判別値を持つ特定の絞り込まれたタイプであることを保証します。

ジェネリックパラメーターから型を正しく絞り込むこのメソッドの型シグネチャを提供しようとしていますが、ユーザーが絞り込み先の型を明示的に指定せずに、識別された共用体から絞り込みを試みることはありません。これは機能しますが、煩わしく、かなり冗長な感じがします。

うまくいけば、この最小限の複製でそれが明らかになるでしょう:

_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');
_

また、「マップされたタイプ」を動的に構築しようとする巧妙な試みも行いました。これはTSのかなり新しい機能です。

_declare class Example2<T extends Action> {
  doSomething<R extends T['type'], TypeMap extends { [K in T['type']]: T }>(key: R): TypeMap[R];
}
_

これは同じ結果に悩まされます。型マップでは_{ [K in T['type']]: T }_計算された各プロパティTの値が _K in_ iterationの各プロパティですが、代わりに同じMyActionsユニオンです。使用できる定義済みのマップされた型をユーザーに提供する必要がある場合は機能しますが、実際には開発者のエクスペリエンスが非常に悪いため、これはオプションではありません。 (組合は巨大です)


このユースケースは奇妙に見えるかもしれません。問題をより使いやすい形に蒸留しようとしましたが、私のユースケースは実際にはObservableに関するものです。それらに精通している場合は、redux-observableによって提供される ofType演算子 をより正確に入力しようとしています。これは基本的に、typeプロパティの filter() の省略形です。

これは、実際に_Observable#filter_および_Array#filter_も型を絞り込む方法と非常に似ていますが、述語コールバックが_value is S_戻り値を持っているため、TSはそれを理解しているようです。ここで同様のことをどのように適応できるかは明確ではありません。

17
jayphelps

プログラミングの多くの優れたソリューションと同様に、間接層を追加することでこれを実現します。

具体的には、ここで実行できるのは、アクションタグの間にテーブルを追加することです(つまり、"Example"および"Another")とそれぞれのペイロード。

type ActionPayloadTable = {
    "Example": { example: true },
    "Another": { another: true },
}

次に、各ペイロードにタグを付けるヘルパータイプを作成し、各アクションタグにマップする特定のプロパティを設定します。

type TagWithKey<TagName extends string, T> = {
    [K in keyof T]: { [_ in TagName]: K } & T[K]
};

これを使用して、アクションタイプと完全なアクションオブジェクト自体の間のテーブルを作成します。

type ActionTable = TagWithKey<"type", ActionPayloadTable>;

これは、(明確ではありませんが)より簡単な書き方でした。

type ActionTable = {
    "Example": { type: "Example" } & { example: true },
    "Another": { type: "Another" } & { another: true },
}

これで、outアクションごとに便利な名前を作成できます。

type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];

そして、私たちはどちらかを書いてユニオンを作ることができます

type MyActions = ExampleAction | AnotherAction;

または、次のように記述することで、新しいアクションを追加するたびにユニオンを更新する手間を省くことができます

type Unionize<T> = T[keyof T];

type MyActions = Unionize<ActionTable>;

最後に、クラスに進みます。アクションをパラメーター化する代わりに、アクションテーブルをパラメーター化します。

declare class Example<Table> {
  doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}

これがおそらく最も意味のある部分でしょう。Exampleは基本的に、テーブルの入力をその出力にマップするだけです。

全体として、コードは次のとおりです。

/**
 * 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);
24

TypeScript 2.8以降では、条件付きの型を使用してこれを実現できます。

// 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');

注:この回答のNarrowActionタイプのアイデアについては@jcalzの功績です https://stackoverflow.com/a/50125960/20489

4
bingles

これは、質問で尋ねられたとおりに機能する TypeScriptの変更 を必要とします。

クラスを単一のオブジェクトのプロパティとしてグループ化できる場合、受け入れられた回答も役立ちます。が大好き Unionize<T>そこにトリック。

実際の問題を説明するために、例を次のように絞り込みます。

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.

もう1つのことは、実際のクラスを関数に渡すことです。ここにクレイジーな例があります:

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
*/

ここでの問題は、colorの値を取得できないことです。あなたはできる RedShape.prototype.color、ただし、値はコンストラクターでのみ適用されるため、これは常に未定義になります。 RedShapeは次のようにコンパイルされます。

var RedShape = /** @class */ (function () {
    function RedShape() {
    }
    return RedShape;
}());

そしてあなたがそうしたとしても:

class RedShape {
  color: 'Red' = 'Red';
}

これは次のようにコンパイルされます。

var RedShape = /** @class */ (function () {
    function RedShape() {
        this.color = 'Red';
    }
    return RedShape;
}());

実際の例では、コンストラクターに複数のパラメーターなどがあるため、インスタンス化もできない場合があります。言うまでもなく、それはインターフェースに対しても機能しません。

次のような愚かな方法に戻す必要があるかもしれません:

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 = ...
*/

またはdoSomethingの表現で:

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 = ...
*/

他の回答のコメントで述べたように、TypeScriptにはすでに推論の問題を修正するための問題があります。この回答の説明にリンクし、問題のより高いレベルの例を提供するコメントを書きました here

2
Meligy

セットアップについてはもう少し詳細ですが、タイプルックアップを使用して目的のAPIを実現できます。

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);
1
Robert Pitts

残念ながら、ユニオンタイプ(つまり、type MyActions = ExampleAction | AnotherAction;)。

ユニオンタイプの値がある場合、ユニオンのすべてのタイプに共通のメンバーにのみアクセスできます。

しかし、あなたのソリューションは素晴らしいです。この方法を使用して、必要なタイプを定義する必要があります。

const result2 = items.doSomething<ExampleAction>('Example');

あなたはそれが好きではありませんが、あなたが望むことをするのはかなり合法的な方法のようです。

1
Alex Yatkevich