具有R和K类型的Typescript扩展了R的键,如何声明R [K]为字符串类型

时间:2019-04-19 15:34:01

标签: typescript

假设我有一个类似于Record的界面

interface Record {
  id: string;
  createdBy: string;
  dateCreated: string;
}

和界面结果类似:

interface Result<R extends Record> {
  records: R[];
  referredRercords: { [ref:string]: Record; }
}

现在,每个类型R的记录都包含一些键K,该键K的字符串值与ReferedRecords中的其中一个引用相关。

我想要一些函数将它们合并为一个连贯的对象,例如:

mergeRecords<R extends Record, K extends keyof R>(result: Result<R>, refs: [K,string][]) {
  return result.records.map((record) => {

    let refsObj = refs.reduce((acc,[refProp, toProp]) => {
      let key = record[refProp] as string; // ISSUE OCCURS HERE
      let val = result.referredRecords[key]; // OR HERE IF CONVERSION EXCLUDED
      return Object.assign(acc, {[toProp]: val});
    },{});
    return Object.assign(record, refsObj);
  });
}

typescript抱怨类型R [K]不能与字符串充分重叠进行转换,并且如果我排除转换,它抱怨类型R [K]无法用于索引类型{[ref:string] :记录; }

这可行,但是我不喜欢它,因为它看起来很笨拙:

let key = record[refProp] as unknown as string;

有没有一种方法可以向打字稿保证类型R [K]确实是字符串,而无需事先进行未知转换?还是只是解决这个问题的方式?

编辑:

这也有效,我更喜欢它,所以除非有人有更好的东西,否则我会继续使用它。

let key = record[refProp];
let val = (typeof key === 'string') ? result.referredRecords[key] : undefined;

1 个答案:

答案 0 :(得分:1)

请注意,standard library中已经有一个名为Record<K, V>的类型,它代表一种对象类型,其键为K,其值为V。我将把您的类型重命名为MyRecord,以区别它们。实际上,内置的Record类型足够有用,以至于我可能最终(偶然地)在回答这个问题时使用它。

所以这是新代码:

interface MyRecord {
    id: string;
    createdBy: string;
    dateCreated: string;
}

interface Result<R extends MyRecord> {
    records: R[];
    referredRecords: { [ref: string]: MyRecord; }
}

function mergeRecords<R extends MyRecord, K extends keyof R>(
    result: Result<R>, refs: [K, string][]
) {
    return result.records.map((record) => {

        let refsObj = refs.reduce((acc, [refProp, toProp]) => {
            let key: string = record[refProp]; // error, not a string 
            let val = result.referredRecords[key]; 
            return Object.assign(acc, { [toProp]: val });
        }, {});
        return Object.assign(record, refsObj);
    });
}

您的主要问题是,您假定 R[K]将是string,但是您没有告诉编译器。因此,mergeRecords()的当前定义很乐意接受这一点:

interface Oops extends MyRecord {
    foo: number;
    bar: string;
}
declare const result: Result<Oops>;
mergeRecords(result, [["foo", "bar"]]); // okay, but it shouldn't accept "foo"!
mergeRecords(result, [["bar", "baz"]]); // okay

糟糕。


理想情况下,您应该约束R(也许还有K)类型,以使编译器知道发生了什么。这是一种实现方法:

// return the keys from T whose property types match the type V
type KeysMatching<T, V> = { 
  [K in keyof T]-?: T[K] extends V ? K : never 
}[keyof T];

function mergeRecords<
    R extends MyRecord & Record<K, string>, 
    K extends KeysMatching<R, string>
>(
    result: Result<R>, refs: [K, string][]
) {
    return result.records.map((record) => {

        let refsObj = refs.reduce((acc, [refProp, toProp]) => {
            let key: string = record[refProp]; // no error now
            let val = result.referredRecords[key];
            return Object.assign(acc, { [toProp]: val });
        }, {});
        return Object.assign(record, refsObj);
    });
}

现在R不仅限于MyRecord,而且还限于Record<K, string>,因此键K上的任何属性都必须具有类型string。这足以使let key: string = ...语句中的错误消失。此外,K被约束为KeysMatching<T, string>(对我们而言是冗余的,但对编译器而言则不是多余的),这意味着T的键的子集的属性是string的值。这在调用函数时有助于IntelliSense。

编辑:KeysMatching<T, V>的工作方式...有多种方法可以执行此操作,但是以上方法是使用mapped type来迭代{{1 }}并创建一个新类型,其属性是K的属性的函数。即使T中的属性是可选的,-? modifier也会使每个新类型的属性成为必需。具体来说,我使用了conditional type来查看T是否与T相匹配。如果是这样,则返回T[K];如果不是,则返回V。因此,如果我们进行了K,则映射的类型将变为never。然后,我们index into将该类型与KeysMatching<{a: string, b: number}, string>映射以获取值类型。因此{a: "a", b: never}变为keyof T,而{a: "a", b: never}["a" | "b"]

现在让我们看看它的作用:

"a" | never

现在,由于"a"处的属性不是interface Oops extends MyRecord { foo: number; bar: string; } declare const result: Result<Oops>; mergeRecords(result, [["foo", "bar"]]); // error on "foo" mergeRecords(result, [["bar", "baz"]]); // okay ,因此第一次调用失败,而由于"foo"处的属性很好,第二次调用仍然成功。

好的,希望能有所帮助。祝你好运!