Подтвердить что ты не робот

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


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

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

4b9b3361

Ответ 1

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

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

Ответ 2

Для этого требуется изменение в 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 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. Вы можете RedShape.prototype.color, но это всегда будет undefined, потому что значение применяется только в конструкторе. 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 есть проблема для исправления проблемы вывода. Я написал комментарий, ссылающийся на объяснение этого ответа, и предоставил более высокий уровень проблемы здесь.

Ответ 3

К сожалению, вы не можете достичь этого поведения с помощью типа union (т.е. type MyActions = ExampleAction | AnotherAction;).

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

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

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

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

Ответ 4

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