Сочетание двух типов в интерфейс элегантно в Typescript - PullRequest
3 голосов
/ 17 марта 2019

Моя цель

У меня есть строка enum E и interface I с идентичными наборами ключей.Я хочу создать новый сопоставленный тип.Для каждого общего ключа k в качестве имени свойства следует использовать значение enum E.k.Тип члена I.k должен быть типом этого нового свойства.

Некоторый фон / мотивация для моего варианта использования

Я получаю объекты из REST API.Я не могу изменить их структуру.Имена ключей объектов очень нечитаемы и уродливы из-за устаревших причин (я моделирую это в FooNames в примере).Это делает разработку болезненной и излишне увеличивает количество ошибок как в коде, так и более критично для понимания при работе с этими объектами и манипулировании ими.

Мы скрыли эти имена, используя собственный чистый интерфейс (смоделированный с помощью "first" | "second" | "third").Однако при записи объектов обратно в бэкэнд им необходимо снова иметь «некрасивую» структуру.Существует несколько десятков типов объектов (с разными наборами полей в каждом), что делает работу с запутанными именами полей настолько болезненной.

Мы пытаемся минимизировать избыточность - при этом все еще проводим проверку статического типа и структурычерез компилятор TS.Таким образом, сопоставленный тип, который запускает проверки типов на основе существующих абстракций, был бы очень полезен.

Пример кода

Может ли приведенный ниже тип BackendObject быть каким-то образом реализован как отображаемый тип вМашинопись? Мне пока не удалось найти способ.См. эту площадку для всего кода в этом вопросе.

// Two simple abstractions per object type, e.g. for a type Foo....
enum FooNames {
  first = 'FIRST_FIELD',
  second = 'TT_FIELD_SECOND',
  third = 'third_field_33'
}
interface FooTypes {
  first: string,
  second: number,
  third: boolean
}
// ... allow for generic well-formed objects with structure and typechecks:
interface FrontendObject<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {[K in FieldNames]: FieldTypes[K]}
}

// Example object in the case of our imaginary type "Foo":
let checkedFooObject: FrontendObject<keyof typeof FooNames,FooTypes> = {
  fields: {  
    first: '',   // typechecks everywhere!
    second: 5,
    third: false,
//  extraProp: 'this is also checked and disallowed'
  }
}

// PROBLEM: The following structure is required to write objects back into database
interface FooBackendObject { 
  fields: {
    FIRST_FIELD: string,
    TT_FIELD_SECOND_TT: number,
    third_field_33: boolean
    // ...
    // Adding new fields manually is cumbersome and error-prone;
    // critical: no static structure or type checks available
  }
}
// IDEAL GOAL: Realize this as generic mapped type using the abstractions above like:
let FooObjectForBackend: BackendObject<FooNames,FooTypes> = {
  // build the ugly object, but supported by type and structure checks
};

Мои попытки до сих пор

1.Enum (Имена) + Интерфейс (типы)

interface BackendObject1<FieldNames extends string, FieldTypes> {
  fields: {
    // FieldTypes cannot be indexed by F, which is now the ugly field name
    [F in FieldNames]: FieldTypes[F]; 
    // Syntax doesn't work; no reverse mapping in string-valued enum
    [F in FieldNames]: FieldTypes[FieldNames.F]; 
  }
}
// FAILURE Intended usage:
type FooObjectForBackend1 = BackendObject1<FooNames,FooTypes>;

2.Вместо этого используйте уродливые ключи для абстракции типа поля

interface FooTypes2 {
  [FooNames.first]: string,
  [FooNames.second]: number,
  [FooNames.third]: boolean,
}

// SUCCESS Generic backend object type
interface BackendObject2<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    [k in FieldNames]: FieldTypes[k]
  }
}
// ... for our example type Foo:
type FooBackend = BackendObject2<FooNames, FooTypes2>
let someFooBackendObject: FooBackend = {
  fields: {
    [FooNames.first]: 'something',
    [FooNames.second]: 5,
    [FooNames.third]: true
  }
}

// HOWEVER....  Generic frontend object FAILURE
interface FrontendObject2<NiceFieldNames extends string, FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    // Invalid syntax; no way to access enum and no matching of k
    [k in NiceFieldNames]: FieldTypes[FieldNames.k]
  }
}

3.Объедините объектную абстракцию в виде кортежей, используя строковые литеральные типы

// Field names and types in one interface:
interface FooTuples {
  first: ['FIRST_FIELD', string]
  second: ['TT_FIELD_SECOND', number]
  third: ['third_field_33', boolean]
}


// FAILURE
interface BackendObject3<TypeTuples> {
  fields: {
    // e.g. { first: string }
    // Invalid syntax for indexing
    [k in TypeTuples[1] ]: string|number|boolean
  }
}

4.Один объект «поля» на тип

// Abstractions for field names and types combined into a single object
interface FieldsObject {
  fields: {
    [niceName: string]: {
      dbName: string,
      prototype: string|boolean|number // used only for indicating type
    }
  }
}
let FooFields: FieldsObject = {
  fields: {
    first: {
      dbName: 'FIRST_FIELD',
      prototype: ''      
    },
    second: {
      dbName: 'TT_FIELD_SECOND',
      prototype: 0
    },
    third: {
      dbName: 'third_field3',
      prototype: true,
    }
  }
}

// FAIL: Frontend object type definition 
interface FrontendObject3<FieldsObject extends string> {
  fields: {
    // Cannot access nested type of 'prototype'
    [k in keyof FieldsObject]: FieldsObject[k][prototype];  
  }
}
// FAIL: Backendobject type definition
interface BackendObject3<FieldsObject extends string> {
  fields: {
    [k in keyof ...]:  // No string literal type for all values of 'dbName'
  }
}

1 Ответ

5 голосов
/ 17 марта 2019

Я думаю, что следующее должно работать для вас:

type BackendObject<
  E extends Record<keyof E, keyof any>,
  I extends Record<keyof E, any>
  > = {
    fields: {
      [P in E[keyof E]]: I[{
        [Q in keyof E]: E[Q] extends P ? Q : never
      }[keyof E]]
    }
  }

interface FooBackendObject extends
  BackendObject<typeof FooNames, FooTypes> { }

Тип BackendObject<E, I> не является интерфейсом, но вы можете объявить интерфейс для любых конкретных значений E и I, как в FooBackendObject выше. Итак, в BackendObject<E, I> мы ожидаем, что E будет отображением ключей (представленных в FooBackendObject значением FooNames , тип которого typeof FooNames ... you не может просто использовать FooNames тип здесь, так как этот не содержит отображение .), а I является отображением значений (представленных в FooBackendObject по интерфейсу FooTypes).

Используемые отображенные / условные типы могут быть немного уродливыми, но это то, что мы делаем: во-первых, ключи объекта fields получены из значений из E ( E[keyof E]). Для каждого ключа P мы находим ключ E, который соответствует ему ({[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E]), и затем используем этот ключ для индексации в I для типа значения.

Давайте объясним {[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E] более подробно. Обычно тип, такой как {[Q in keyof E]: SomeType<Q>}[keyof E], будет объединением SomeType<Q> для всех Q в keyof E. Вы можете обналичить его с конкретным типом, если это имеет больше смысла ... если E равно {a: string, b: number}, тогда {[Q in keyof E]: SomeType<Q>} будет {a: SomeType<'a'>, b: SomeType<'b'>}, а затем мы посмотрим его значения в клавиши keyof E, которые {a: SomeType<'a'>, b: SomeType<'b'>}['a'|'b'], которые становятся SomeType<'a'> | SomeType<'b'>. В нашем случае SomeType<Q> - это E[Q] extends P ? Q : never, что означает Q, если E[Q] соответствует P, и never в противном случае. Таким образом, мы получаем объединение Q значений в keyof E, для которых E[Q] соответствует P. Должен быть только один из них (если в enum нет двух ключей с одинаковым значением).

Для вас может быть полезно выполнить упражнение ручной оценки BackendObject<typeof FooNames, FooTypes>, чтобы увидеть, как это происходит.

Вы можете убедиться, что он ведет себя как нужно. Надеюсь, это поможет. Удачи!

...