是否扩展并仅指定已知属性?属性

2023-09-03 13:33:25 作者:不渡

我正在尝试提供一个接口,该接口接受给定类型的映射,并将其用于运行时逻辑和编译时类型。

类似:

type SomeType = {
  a: string
  b: { a: string, b: string }
}
magicalFunction({ a: 1 }) // 1. return type is {a: string}
magicalFunction({ b: 1 }) // 2. return type is { b: { a: string, b: string } }
magicalFunction({ b: { a: 1 } }) // 3. return type is { b: { a: string } }
magicalFunction({ a: 1, c: 1 }) // 4. compile-time error since there's no 'c' on SomeType
BeforeConnectOuterDataSource

(在现实世界中,magicalFunctionSomeType作为泛型参数。对于这个问题,我们可以假定它只是用SomeType硬编码。)

使用映射类型时,我得到了前3种行为:

export type ProjectionMap<T> = {
  [k in keyof T]?: T[k] extends object
    ? 1 | ProjectionMap<T[k]>
    : 1

export type Projection<T, P extends ProjectionMap<T>> = {
  [k in keyof T & keyof P]: P[k] extends object
    ? Projection<T[k], P[k]>
    : T[k]
}

type SomeType = {
  a: string
  b: { a: string, b: string }
}

function magicalFunction<P extends ProjectionMap<SomeType>>(p: P): Projection<SomeType, P> {
  /* using `p` to do some logic and construct something that matches `P` */
  throw new Error("WIP")
}

const res = magicalFunction({ a:1 })
// etc

问题是我在指定额外属性时没有遇到编译错误--例如{a:1, c:1}。现在,它实际上完全有意义-在这种情况下,编译器将P推断为typeof { a:1, c:1 },这是ProjectionMap<SomeType>结果的有效子类,因为所有字段都是可选的。但是,有没有什么神奇的咒语可以让这一切像我所描述的那样奏效呢?另一种指定类型的方式,还是某种映射类型魔术?

据我所知,在为p参数指定类型时操作P会破坏类型推断。如果我们假设有一个键过滤类型(对于未知的键,递归地失败),那么将签名更改为magicalFunction<...>(p: FilterKeys<P, SomeType>): ... 会产生一个类似magicalFunction({a:1})Resolve tomagicalFunction<unknown>的调用。

背景

我在这里的最终目标是创建一个存储库类,它被类型化为特定的实体,并且可以对mongo执行投影查询。为了类型安全,我希望自动完成投影字段、指定不存在的字段时的编译错误以及与投影匹配的返回类型。

例如:

class TestClass { num: number, str: string }
const repo = new Repo<TestClass>()
await repo.find(/*query*/, { projection: { num: 1 } }) 
// compile-time type: { num: number}
// runtime instance: { num: <some_concrete_value> }

推荐答案

如您所知,TypeScrip中的对象类型通过structural subtyping匹配,因此打开和可扩展。如果Base是对象类型且Sub extends Base,则SubBase的一种,应该允许在需要Base的地方使用Sub

const sub: Sub = thing; 
const base: Base = sub; // okay because every Sub is also a Base

这意味着Base的每个已知性质一定是Sub的已知性质。但反之亦然:并不是说Sub的每个已知属性都必须是Base的已知属性。实际上,您可以轻松地向Sub添加新的已知属性,而不会违反结构子类型:

interface Base {
    baseProp: string;
}
interface Sub extends Base {
    subProp: number;
}
const thing = { baseProp: "", subProp: 123 };
Sub仍然是Base的一种,即使它有额外的性质。因此Base的属性不能仅限于编译器已知的属性。

因此,TypeScrip中的对象类型是开放的和可扩展的,而不是封闭的或精确的。目前在Exact<Sub>形式的打字脚本中没有特定的类型,该类型只允许存在Sub的已知属性,并拒绝任何具有额外属性的内容。在microsoft/TypeScript#12936有一个长期开放的请求,要求支持这样的";确切类型";,但该语言当前没有这样的请求。

更复杂的是,object literals确实经历了所谓的"excess property checking",在这种情况下,意外的属性会产生编译器警告。但这与其说是类型系统特性,不如说是Linter规则。即使以下代码会生成编译器警告:

const excessProp: Base =
    { baseProp: "", subProp: 123 }; // error!
// ---------------> ~~~~~~~~~~~~
// Object literal may only specify known properties,
// and 'subProp' does not exist in type 'Base'

这并不意味着分配给excessProp的值是无效的Base。您仍然可以这样做:

const excessProp = { baseProp: "", subProp: 123 };
const stillOkay: Base = excessProp; // no error
因此,在某种意义上,确实没有办法在所有可能的情况下强制执行您正在寻找的约束。不管我们想出什么解决方案,总有人能做到:

const x = { a: 1, c: 1 } as const; // okay
const y: { a: 1 } = x; // okay
magicalFunction(y); // okay

这可能不太可能,您不想担心以防御方式实现magicalFunction(),但这是需要记住的事情。额外的属性不会违反TypeScrip对象类型,因此额外的属性可能会潜入。

不管怎样,虽然目前还不能说像ProjectionMap<SomeType>这样的特定类型不能包含多余的属性,但您可以使其成为P extends ProjectionMap<SomeType, P>形式的自引用generic constraint。您可以将P视为要检查的候选类型。如果P在某处有多余的属性,您可以使ProjectionMap<SomeType, P>与其不兼容。

这基本上是模拟精确类型的变通方法。我们有泛型X extends Exactly<T, X>,而不是特定的Exact<T>,当且仅当X可赋值给T但没有多余的属性时,泛型X extends Exactly<T, X>成立。有关详细信息,请参阅MICROSOFT/TypeScrip#12936上的this comment。

在这里:

type Exactly<T, X> = T & Record<Exclude<keyof X, keyof T>, never>
因此,类型Exactly<T, X>使用the Exclude<T, U> utility type来获取X中不存在的list of keys...也就是说,多余的关键点。它使用the Record<K, V> utility type来创建一个对象类型,该对象类型的键是这些多余的键,其值是the never type,在JavaScript中没有任何值可以赋给它。例如,Exactly<{a: string}, {a: string, b: number}>将等同于{a: string, b: never}。当X具有不在T中的密钥时,never会导致X extends Exactly<T, X>失败。

现在我们可以在ProjectionMap定义内递归使用Exactly(以禁止多余的键,即使在嵌套的对象类型中也是如此):

type ProjectionMap<T, P extends ProjectionMap<T, P> = T> = Exactly<{
    [K in keyof T]?: T[K] extends object
    ? 1 | ProjectionMap<T[K], P[K]>
    : 1 }, P>

然后Projection不变:

type Projection<T, P extends ProjectionMap<T>> = {
    [K in keyof T & keyof P]: P[K] extends object
    ? Projection<T[K], P[K]> : T[K]        
} extends infer O ? { [K in keyof O]: O[K] } : never

magicalFunctionP约束为ProjectionMap<SomeType, P>

type SomeType = {
    a: string
    b: { a: string, b: string }
}

declare function magicalFunction<
  P extends ProjectionMap<SomeType, P>
>(p: P): Projection<SomeType, P>

让我们测试一下:

magicalFunction({ a: 1 }) // {a: string}
magicalFunction({ b: 1 }) // { b: { a: string, b: string } }
magicalFunction({ b: { a: 1 } }) // { b: { a: string } }
magicalFunction({ a: 1, c: 1 }) // error, no 'c' on SomeType
magicalFunction({ b: { a: 1, z: 1 } }) // error, no 'z' on SomeType["b"]

看起来不错!所有你想接受的案例都被接受了,所有你想拒绝的案例都被拒绝了。当然,以前的magicalFunction(y)问题表单并没有消失,但是对于您需要支持的用例来说,这个实现可能已经足够好了。

Playground link to code