Сужение возвращаемого типа из общего дискриминируемого объединения в TypeScript

у меня есть метод класса, который принимает один аргумент в качестве строки и возвращает объект, который имеет соответствие type собственность. Этот метод используется для сужения различаемого типа объединения вниз и гарантирует, что возвращаемый объект всегда будет определенного суженного типа, который имеет предоставленный type значение различий.

я пытаюсь предоставить сигнатуру типа для этого метода, которая правильно сузит тип от общего параметра, но ничего I try сужает его от дискриминируемого объединения без явного предоставления пользователем типа, к которому он должен быть сужен. Это работает, но раздражает и чувствует себя совершенно излишним.

надеюсь, это минимальное воспроизведение дает понять:

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 шаг а так же MyActions Союза. Если я требую, чтобы пользователь предоставил предопределенный сопоставленный тип, который я могу использовать, это сработает, но это не вариант, поскольку на практике это будет очень плохой опыт разработчика. (профсоюзы огромны)


этот вариант использования может показаться странным. Я попытался дистиллировать свою проблему в более потребляемую форма, но мой вариант использования на самом деле касается наблюдаемых. Если вы знакомы с ними, я пытаюсь более точно ввести ofType оператор, предоставленный redux-observable. Это в основном стенография для filter() на type свойства.

это на самом деле очень похоже на то, как Observable#filter и Array#filter также сужают типы, но TS, похоже, выясняет это, потому что обратные вызовы предикатов имеют value is S возвращаемое значение. Непонятно, как Я мог бы адаптировать что-то подобное здесь.

5 ответов


как и многие хорошие решения в программировании, вы достигаете этого, добавляя слой косвенности.

в частности, мы можем добавить таблицу между тегами действий (т. е. "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 },
}

теперь мы можем создавать удобные имена для каждого из действий:

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

для этого требуется изменения в 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.

еще одна вещь, которую вы можете сделать, это передать фактический класс функции. Вот такой сумасшедший пример:

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. Ты можешь!--9-->, но это всегда будет неопределенным, потому что значение применяется только в конструкторе. 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 есть проблема для исправления проблемы вывода. Я написал комментарий, ссылающийся на объяснение этого ответа и предоставляющий пример более высокого уровня проблемы здесь.


к сожалению, вы не можете достичь этого поведения, используя тип объединения (т. е. type MyActions = ExampleAction | AnotherAction;).

если у нас есть значение с типом объединения, мы можем получить доступ только к членам, общим для всех типов в объединении.

тем не менее, ваше решение отлично. Вам просто нужно использовать этот способ, чтобы определить тип, который вам нужен.

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

хотя вам не нравится это, это кажется довольно законным способом сделать то, что вы хотите.


немного подробнее о настройке, но мы можем достичь желаемого 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);

начиная с 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');

Примечание: кредит @jcalz для идеи типа NarrowAction из этого ответа https://stackoverflow.com/a/50125960/20489