Как сопоставить объекты в различаемом объединении с функциями, с помощью которых они могут быть вызваны?

#typescript #typescript-typings #typescript-generics

#typescript

Вопрос:

В vanilla JS я могу написать некоторый код, который выглядит примерно так:

 function renderTextField(props) { }
function renderSelectField(props) { }

const fieldMapping = {
    text: renderTextField,
    select: renderSelectField,
};

function renderField(field) {
    const renderFn = fieldMapping[field.type];
    renderFn(field.data);
}
  

Использование 2 типов полей просто для уменьшения размера примера, но приятная особенность этого кода заключается в том, что универсальному методу не нужно знать о типе поля, и он делегирует решение отображению, предоставленному fieldMapping .

Я пытаюсь написать что-то подобное в TypeScript. Но я не могу понять, как заставить типы работать и при этом использовать объект для обеспечения сопоставления между type и функцией для делегирования.

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

 type TextFieldData = { value: string }
type TextField = { type: 'text', data: TextFieldData }
type SelectFieldData = { options: string[], selectedValue: string }
type SelectField = { type: 'select', data: SelectFieldData }
type FormField = TextField | SelectField

function renderTextField(props: TextFieldData) {}
function renderSelectField(props: SelectFieldData) {}

const fieldMapping = {
  text: renderTextField,
  select: renderSelectField,
}

// This won't work!
function renderFieldDoesNotWork(field: FormField) {
  const renderFn = fieldMapping[field.type]

  // Type 'TextFieldData' is missing the following properties from type 'SelectFieldData': options, selectedValue
  renderFn(field.data)
}

// This works
function renderFieldWorks(field: FormField) {
  if (field.type === 'text') {
    const renderFn = fieldMapping[field.type]
    renderFn(field.data)
  } else if (field.type === 'select') {
    const renderFn = fieldMapping[field.type]
    renderFn(field.data)
  }
}
  

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

1. Похоже, еще один вопрос, который нужно добавить в кучу… В TypeScript отсутствует хорошая поддержка коррелированных типов записей , поэтому вам, вероятно, потребуется использовать утверждения типа.

Ответ №1:

Я боюсь, что вам придется использовать утверждение типа, чтобы избежать дублирования кода здесь. Система типов TypeScript просто не имеет хорошей поддержки для этих «коррелированных типов записей» или любых манипуляций, которые основаны на взаимодействии двух значений, типизированных объединением, где объединения не являются независимыми.

Вы уже пришли к обходному пути с избыточным кодом в — switch statement; вот обходной путь с небезопасным утверждением:

 function assertNarrowFunction<F extends (arg: any) => any>(f: F) {
  return f as (arg: Parameters<F>[0]) => ReturnType<F>; // assert
}
  

Это принимает функцию типа объединения, например ((a: string)=>number) | ((a: number)=>boolean) , и небезопасно сужает ее до функции, которая принимает объединение своих типов параметров и возвращает объединение возвращаемого типа, например ((a: string | number) => string | number) . Это небезопасно, потому что функция прежнего типа объединения может быть чем-то вроде const f = Math.random()<0.5 ? ((a: string)=>a.length) : ((a: number)=>number.toFixed()) , что определенно не соответствует ((a: string | number) => string | number) . Я не могу безопасно вызывать f(5) , потому что maybe f — это функция длины строки.

В любом случае, вы можете использовать это небезопасное сужение renderFn , чтобы отключить ошибку:

 function renderFnAssertion(field: FormField) {
  const renderFn = assertNarrowFunction(fieldMapping[field.type]);
  renderFn(field.data); // okay
}
  

Вы немного солгали компилятору о типе renderFn … не настолько, чтобы он принимал любой старый аргумент (например, renderFn(123) завершится ошибкой по желанию), но достаточно, чтобы это позволило:

 function badRenderFn(field1: FormField, field2: FormField) {
  const renderFn1 = assertNarrowFunction(fieldMapping[field1.type]);
  renderFn1(field2.data); // no error!!! ooops
}
  

Поэтому вы должны быть осторожны.

Хорошо, надеюсь, это поможет; удачи!

Ссылка на код

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

1. Спасибо за отличное объяснение. Хотелось бы, чтобы у TS была лучшая поддержка для этого!

Ответ №2:

Возможный подход

Вот он на игровой площадке.

Преимущество этого подхода заключается в том, что если мы удалим всю информацию о типе, у нас останется исходный ванильный JavaScript из вашего вопроса. Единственным недостатком, который я вижу, является as решение проблемы отсутствия отмеченных коррелированных типов jcalz записей. В этом случае это кажется прекрасным, потому что мы знаем больше, чем знает компилятор, и мы не теряем никакой безопасности типов.

 type TextFieldData = { value: string }
type TextField = { type: 'text', data: TextFieldData }
type SelectFieldData = { options: string[], selectedValue: string }
type SelectField = { type: 'select', data: SelectFieldData }
type FormField = TextField | SelectField

function renderTextField(props: TextFieldData) { }
function renderSelectField(props: SelectFieldData) { }

const fieldMapping = {
    text: renderTextField,
    select: renderSelectField,
}

// This is the new block of code.
type FindByType<Union, Type> = Union extends { type: Type } ? Union : never;
type TParam<Type> = FindByType<FormField, Type>['data'];
type TFunction<Type> = (props: TParam<Type>) => void;

function renderFieldDoesNotWork(field: FormField) {
    // This is the cast that seems unavoidable without correlated record types.
    const renderFn = fieldMapping[field.type] as TFunction<typeof field.type>;
    renderFn(field.data)
}
  

Дополнительная безопасность типов

В коде из вашего вопроса компилятор не будет жаловаться, если разработчик допустил ошибку в таком сопоставлении:

 const fieldMappingOops = {
    select: renderTextField, // no compiler error
    text: renderTextField,
}
  

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

 type FieldMapping = {
    [Key in FormField['type']]: TFunction<Key>;
}

const fieldMappingOops: FieldMapping = {
    select: renderTextField, // compiler error
    text: renderTextField,
}
  

Дополнительные сведения

Этот комментарий Райана Кавано на GitHub вдохновил на этот подход. Это FindByType дает нам некоторую способность сужения типов, которую мы получаем от помеченных типов объединения / различаемых объединений, когда мы используем их с switch if оператором and .

Вот как типы, связанные с этим комментарием, расширяются при вводе соответствующих входных данных:

 // type f1 = {
//     type: "text";
//     data: TextFieldData;
// }
type f1 = FindByType<FormField, 'text'>;

// type f2 = {
//     value: string;
// }
type f2 = TParam<'text'>;

// type f3 = (props: TextFieldData) => void
type f3 = TFunction<'text'>;

// type f4 = TextField | SelectField
type f4 = FindByType<FormField, 'text' | 'select'>;

// type f5 = TextFieldData | SelectFieldData
type f5 = TParam<'text' | 'select'>;

// type f6 = (props: TextFieldData | SelectFieldData) => void
type f6 = TFunction<'text' | 'select'>;
  

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

1. Спасибо — здесь есть несколько полезных лакомых кусочков, мне это нравится FindByType !