首页 > 解决方案 > 如何告诉 TypeScript 接口 T 比具有索引签名的类型 U 窄?

问题描述

我有一个函数可以验证 JSON 响应以确保它对应于给定的形状。

这是我定义所有可能的 JSON 值的类型——取自https://github.com/microsoft/TypeScript/issues/1897#issuecomment-338650717

type AnyJson = boolean | number | string | null | JsonArray | JsonMap;
type JsonMap = { [key: string]: AnyJson };
type JsonArray = AnyJson[];

现在我有一个函数可以在给定要验证的对象和一个具有 shape 的模拟对象的情况下进行验证T

function isValid<T extends AnyJson>(obj: AnyJson, shape: T): obj is T {
  // ... implementation
}

但是,当我尝试使用接口和真实对象调用函数时,我Thing在类型参数中得到类型错误

interface Response {
  Data: Thing[]; // Thing is an interface defined elsewhere
};

isValid<Response>(data, { Data: [] })
//      ^^^^^^^^
Type 'Response' does not satisfy the constraint 'AnyJson'.
  Type 'Response' is not assignable to type 'JsonMap'.
    Index signature is missing in type 'Response'.

Response奇怪的是,当是类型而不是接口时,这种情况不会发生,比如

type Response = {
  Data: Thing[];
};

但后来我确实得到了同样的错误,但Thing它本身仍然是一个接口:

Type 'Response' does not satisfy the constraint 'AnyJson'.
  Type 'Response' is not assignable to type 'JsonMap'.
    Property 'Data' is incompatible with index signature.
      Type 'Thing[]' is not assignable to type 'AnyJson'.
        Type 'Thing[]' is not assignable to type 'JsonArray'.
          Type 'Thing' is not assignable to type 'AnyJson'.
            Type 'Thing' is not assignable to type 'JsonMap'.
              Index signature is missing in type 'Thing'.

所以我的问题是为什么这种预期的缩小不会发生在接口上而只发生在类型上?

标签: typescript

解决方案


这是一个已知问题(参见 microsoft/TypeScript#15300)隐式索引签名仅适用于对象文字和type别名,而不适用于interfaceclass类型。目前是设计使然在没有确切类型的情况下推断隐式索引签名不是类型安全的。例如,Response不知道类型值具有Data属性。它可能具有与AnyJson(例如,interface FunkyResponse extends Response { otherProp: ()=>void })不兼容的属性,因此编译器拒绝在那里推断隐式索引签名。这样做在技术上是不安全的type别名也是如此,但无论出于何种原因,一个是允许的,另一个是不允许的。如果你想看到这种变化,你可能想去那个问题,给它一个和/或描述你的用例,如果你认为它很有吸引力。实际上看起来有人已经提到了这个用例


所以,除非这个问题得到解决,否则我们能做什么?通常在这些情况下,我发现将我想要的类型表示为通用约束而不是具体类型更容易。索引签名改为映射类型。目标是提出一个泛型类型别名JsonConstraint<T>,以便将有效的 JSON 类型 likeResponse分配给,JsonConstraint<Response>无效的 JSON 类型 like分配给。这是我可能写它的一种方式:DateJsonConstraint<Date>

type JsonConstraint<T> = boolean | number | string | null | (
    T extends Function ? never :
    T extends object ? { [K in keyof T]: JsonConstraint<T[K]> }
    : never
)

如果是可接受的原始类型之一,T extends JsonConstraint<T>则为真,如果是函数,则为假,否则它会递归到每个属性并检查每个属性。这种递归应该适用于对象和数组,因为TypeScript 3.1 引入了映射的元组/数组类型TTT

现在我想编写函数签名isValid<T extends JsonConstraint<T>>(obj: AnyJson, shape: T): obj is AnyJson & T,但这是一个不可接受的循环约束。它有时会发生。修复它的一种方法是将签名更改为isValid<T>(obj: AnyJson, shape: T & JsonConstraint<T>): obj is AnyJson & T. T这将从推断shape,然后检查JsonConstraint<T>是否仍可分配给shape。如果是这样,那就太好了。如果不是,则该错误应提供信息。

所以这里是isValid()

function isValid<T>(obj: AnyJson, shape: T & JsonConstraint<T>): obj is typeof obj & T {
    return null!; // impl here
}

现在让我们测试一下:

declare const data: AnyJson

declare const response: Response;
if (isValid(data, response)) {
    data.Data.length; // okay
};

这样就可以正常工作,如您所愿。让我们看看它对其他类型的行为是否符合预期。我们不应该undefined用作属性类型:

isValid(data, { undefinedProp: undefined }); // error! 
//            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Types of property 'undefinedProp' are incompatible

或函数值属性:

isValid(data, { deeply: { nested: { property: { func: () => 1 } } } }); // error!
// Types of property 'func' are incompatible.

或者一个Date(失败,因为它有各种不可序列化的方法):

isValid(data, new Date()); // error!
// Types of property 'toString' are incompatible.

最后,我们应该能够正确地使用stringnumberbooleannull和数组/对象:

isValid(data, {
    str: "",
    num: 1,
    boo: Math.random() < 0.5,
    nul: null,
    arr: [1, 2, 3],
    obj: { a: { b: ["a", true, null] } }
}); // no error

看起来不错。好的,希望有帮助;祝你好运!

Playground 代码链接


推荐阅读