В Typescript, как выбрать тип из объединения, используя свойство literal type указанного типа?

#typescript

#typescript

Вопрос:

У меня есть редуктор в react. Действие может быть одного из 8 типов, но для простоты давайте представим, что существует только 2 типа

 type Add = {
  type: 'add';
  id: string;
  value: string;
}

type Remove = {
  type: 'remove';
  id: string;
}

type Action = Add | Remove;
  

Вместо использования switch case я использую объект обработчиков, где каждый обработчик представляет собой функцию, которая обрабатывает определенное действие

 const handlers = {
  add: (state, action) => state,
  remove: (state, action) => state,
  default: (state, action) => state,
}

const reducer = (state, action) => {
  const handler = handlers[action.type] || handlers.default;
  return handler(state, action);
}
  

Теперь я хочу ввести handlers объект соответствующим образом. Таким образом, функция-обработчик должна принимать действие типа, соответствующего его ключу в handlers объекте.

 type Handlers = {
  [key in Action["type"]]: (state: State, action: Action) => State
//                                                ↑this here should be the action which has type
//                                                matching to it's key. So when the key is
//                                                'add', it should be of type Add, and so on.
}
  

Все, о чем я мог подумать, это явно указать ключ и соответствующий тип действия. Есть ли способ «выбрать» тип из объединения в соответствии со значением ключа?

Ответ №1:

Извлечение только типа является простым.

 type Add = {
    type: 'add';
    id: string;
    value: string;
}
  
type Remove = {
    type: 'remove';
    id: string;
}
  
type Action = Add | Remove;

type addType = Extract<Action, {type: 'add'}>;
  

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

 type OfUnion<T extends {type: string}> = {
    [P in T['type']]: Extract<T, {type: P}>
}
  

Это сопоставленный тип. Это похоже на функцию type, она принимает тип, преобразует его и возвращает этот новый тип. Так OfUnion что ожидает чего-то, что имеет форму T extends {type: string} . Это означает, что он будет принимать {type: 'add'} или тип объединения, подобный type Action = Add | Remove . Важно то, что каждый элемент этого типа объединения имеет type: string свойство.

Когда у вас есть тип объединения и каждый параметр имеет общее свойство, вы можете безопасно получить доступ к этому свойству (в данном случае, type ). Это распространяется, поэтому Action['type'] дает нам 'add' | 'remove' . Сопоставленный тип создает объект, который имеет ключи [P in T['type']] , он же. ! 'add' | 'remove'

Тип add свойства должен быть типом этого действия, Add . Вы можете добраться до этого с помощью того Extract<> вызова, который я сделал. Это, по сути, фильтрует объединение, чтобы включать только элементы, соответствующие определенному типу. В этом случае фильтруйте только то, что соответствует {type: 'add'} . Единственный параметр, который соответствует этому, — это {type: 'add', id: string, value: string} объект, так что это то, на что он установлен.

Теперь у вас есть объект, который выглядит следующим образом:

 {
    add: {
        type: 'add';
        id: string;
        value: string;
    },
    remove: {
        type: 'remove';
        id: string;
    }
}
  

Это здорово! Но нам нужно преобразовать это в обработчик. Таким образом, обработчик по-прежнему будет иметь add remove ключи и , но вместо типов объектов они будут функциями, которые принимают тип объекта и возвращают что угодно.

 type Handler<T> = {
    [P in keyof T]: (variant: T[P]) => any
}
  

Здесь примером P будет 'add' и T[P] будет этот Add тип. Итак, теперь обработчик должен принять Add объект и что-то с ним сделать. Это именно то, что мы хотим.

теперь вы можете написать типобезопасную функцию сопоставления с автозаполнением:

 function match<
    T extends {type: string},
    H extends Handler<OfUnion<T>>,
> (
    obj: T,
    handler: H,
): ReturnType<H[keyof H]> {
    return handler[obj.type as keyof H]?.(obj as any);
}
  

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

отредактированная ссылка на игровую площадку

match() Функция должна взять объект, который будет иметь тип объединения, подобный Action , а затем ограничить объект-обработчик, который он ожидает, определенным контрактом (типы обработчиков, которые мы только что создали). Лучше делать это как функцию, а не для каждого случая, потому что тогда вам не нужно постоянно переписывать определения типов! Функция принесет их. Также лучше делать это независимо, без объекта состояния, потому что вы все равно можете использовать объект состояния внутри ветвей обработчика. Найдите «замыкания» для получения дополнительной информации, но вы также можете поверить мне, что state объект будет находиться в области видимости. Но теперь мы также можем использовать match в местах, где state это не имеет значения, например, в .jsx .

 const reducer = (state: State, action: Action) => match(action, {
  add: ({id, value}) => state,
  remove: ({id}) => state,
})
  

Поверьте мне, использовать эти типы проще, чем писать их. Для их написания вам нужны продвинутые знания typescript, но любой новичок может их использовать.


Возможно, вас заинтересует моя библиотека variant , которая делает именно это. На самом деле эти примеры — просто упрощенные версии моего библиотечного кода. Моя match функция автоматически ограничит объект обработчика, который вы передаете, и возвращаемый тип будет объединением всех ветвей обработчика.

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

Ответ №2:

Вы можете использовать Extract условный тип для извлечения типа из объединения на основе базового типа нужного типа.

 type Handlers = {
  [key in Action["type"]]: (state: State, action: Extract<Action, { type: key }>) => State
}
  

Ссылка на игровую площадку

Комментарии:

1. Он выдает ошибку, когда я использую обработчик в редукторе. В нем говорится: «Пересечение было уменьшено до «никогда», потому что свойство «тип» имеет конфликтующие типы». игровая площадка

2. @AhmadMayo вы не получите это для проверки типа (даже с Handlers типом, написанным вручную). Ts не может сопоставить тип action и handlers[action.type] , ваш единственный выбор — утверждение типа или это handlers[action.type](state, action as never)

Ответ №3:

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

 type Add = {
  type: 'add';
  id: string;
  value: string;
}

type Remove = {
  type: 'remove';
  id: string;
}

type Action = Add | Remove;
type State = any // replace with a type to define the shape of your state

// All reducing functions have the same interface
type ReducingFunction = (state: State, action: Action) => State

// Create a type with the Action type as the key, and a reducing callback as the value
// Finally, add `default` to this type
type Handlers = Record<Action['type'], ReducingFunction> amp; { default: ReducingFunction }

const handlers: Handlers = {
  add: (state, action) => state,
  remove: (state, action) => state,
  default: (state, action) => state,
}

const reducer = (state: State, action: Action) => {
  const handler = handlers[action.type] || handlers.default;
  return handler(state, action);
}
  

Игровая площадка

Комментарии:

1. но теперь обработчики не набраны надлежащим образом. Обработчик add должен принимать только действие типа Add, но не Remove, и наоборот. Если я по какой-то причине попытался получить доступ к content свойству действия внутри обработчика удаления, компилятор не будет жаловаться, но он должен