在Typescript中将两种类型完美地组合成一个界面

时间:2019-03-17 15:27:14

标签: typescript generics types typescript-generics

我的目标

我有一个字符串enum E和一个interface I,具有相同的键集。我想构造一个新的映射类型。对于每个共享密钥k,它应使用枚举值E.k作为属性名称。成员I.k的类型应该是此新属性的类型。

我的用例的某些背景/动机

我从REST API获取对象。我无法改变他们的结构。由于遗留原因,这些对象的键名非常难以理解且丑陋(我在示例中的FooNames中进行了模拟)。 这使开发过程痛苦不堪,并不必要地增加了代码中的错误,但在处理和操作这些对象时,在理解上却更为关键。

我们已经使用我们自己的干净接口(通过"first" | "second" | "third"模拟)隐藏了这些名称。但是,将对象写回后端时,它们需要再次具有“丑陋”的结构。 对象类型有几十种(每种类型具有不同的字段集),这使得使用混乱的字段名称变得如此痛苦。

我们正在尝试最小化冗余-同时仍通过TS编译器进行静态类型和结构检查。因此,基于现有抽象触发类型检查的映射类型将非常有帮助。

代码示例

下面的BackendObject类型可以以某种方式在Typescript中实现为映射类型吗?到目前为止,我仍然找不到方法。有关此问题中的所有代码,请参见this playground

// 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。枚举(名称)+接口(类型)

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 个答案:

答案 0 :(得分:4)

我认为以下应该适合您:

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一样声明IFooBackendObject的任何特定具体值的接口。因此,在BackendObject<E, I>中,我们期望E是键的映射(在FooBackendObject中由FooNames value 表示,其类型为{ {1}} ...您不能仅在此处使用typeof FooNames ,因为该doesn't contain the mapping。)和{{1 }}到值的映射(由接口FooNamesI中表示)。

正在使用的映射/条件类型可能有点难看,但这就是我们正在做的事情:首先,FooBackendObject对象的键来自{的 values {1}}(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,然后我们为{{3 }}在键{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匹配的neverQ个值的并集。应该只有其中之一(如果枚举没有两个具有相同值的键)。

进行手动评估keyof E来了解它的发生可能对您很有用。

您可以验证其行为是否符合预期。希望能有所帮助。祝你好运!