禁止泛型函数中对象的非枚举键

Disallow non-enum keys for object in generic function

考虑以下代码:playground

const enum SomeEnum {
  a = "a",
  b = "b",
}

type Smth = {
  [key: string]: {
    args?: { [key: string]: () => string }
  } & {
    [key in SomeEnum]: string
  }
}

function f<S extends Smth>(obj: S): { [key in keyof S]: { [key2 in SomeEnum]: S[key] extends { args: any } ? () => string : string } } {
  return null!
}

const t = f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    a: "zzz",
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // Should be an error
  }
})

它编译得很好并给变量 t type

const t: {
    prop1: {
        a: string;
        b: string;
    };
    prop2: {
        a: () => string;
        b: () => string;
    };
    prop3: {
        a: string;
        b: string;
    };
}

唯一的问题是prop3中用这一行编译的代码:

oooops: "???", // Should be an error

我希望将 args 以外的非枚举值作为键传递是错误的。

删除此行后,代码的工作方式应该与现在相同。

怎样才能做到?

如果可能的话,我希望在丢失或额外字段的情况下错误指向该字段、所有邻居字段或父字段,而不是整个对象。

我尝试将类型更改为

type Smth = {
  [key: string]: {
    args?: { [key: string]: () => string }
  } & {
    [key in SomeEnum]: string
  } & {
    [key in string]?: key extends `${SomeEnum}` ? string : never
  }
}

但它打破了正常情况。

[key in Exclude<string, `${Lang}`>] 之类的东西也不起作用。

Why did you write f as a generic function which accepts any type that extends Smth? Just write it as a regular function or remove the extends constraint

因为我需要正确的return类型:

为什么要将 f 编写为接受 extends Smth 的任何类型的通用函数?只需将其写为常规函数或删除 extends 约束:

const enum SomeEnum {
  a = "a",
  b = "b",
}

type Smth = {
  [key: string]: {
    args?: { [key: string]: () => string }
  } & {
    [key in SomeEnum]: string
  }
}

function f(obj: Smth): { [key in keyof Smth]: { [key in SomeEnum]: () => string } } {
  return null!
}

const t = f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    a: "zzz",
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // Should be an error
  }
})

TypeScript playground

在某种程度上,不可能完全阻止对象类型中的额外键。 TypeScript 的整个类型系统是围绕 structural typing where object types only care about known properties and don't prohibit unknown properties. Object types in TypeScript are considered open and extendible, and not closed or "exact" (see microsoft/TypeScript#12936 构建的,用于支持精确类型的功能请求。

这意味着无论你如何编写 f() 的调用签名,有人最终可能会在不做任何不合理的事情的情况下传递未知属性:

const val = { a: "ok", b: "fine", oooops: "???" };
const smth: { a: string, b: string } = val; // <-- this is accepted
f({ prop1: smth }) // and so is this

val分配给smth是可以接受的,因为val具有{a: string, b: string}中的所有属性。事实上,有一个额外的 oooops 属性 不是错误。

此处产生错误的检查类型:

const notAllowed: { a: string, b: string } =
  { a: "ok", b: "fine", oooops: "???" }; // error!
// -------------------> ~~~~~~~~~~~~~
// Object literal may only specify known properties

被称为 excess property checking 并且仅在编译器认为额外属性的知识将被编译器 丢弃 的地方触发。在 notAllowed 中,您会立即丢弃 oooops 属性 的知识,因此编译器认为将它放在那里可能是一个错误。这更像是一条 linter 规则,而不是类型安全规则。

但是在const smth: {a: string, b: string } = val中,val对象仍然单独存在,编译器知道oooops。在您的原始代码中, S 泛型类型参数将被推断为具有 oooops 的内容。额外的 属性 不会被丢弃,因此不会被视为错误。

所以这里的一个建议是只接受多余的属性,确保 f() 的实现在获得它们时不会做坏事,不要担心。


如果你真的想禁止多余的键,你可以改变f()的调用签名来实现。我这里的第一种方法适用于简单的情况,在这种情况下,您只关心跟踪的 keysobj 参数:

type SmthValue =
  { args?: { [K: string]: () => string } } &
  { [K in SomeEnum]: string };

declare function f<K extends PropertyKey>(
  obj: { [P in K]: SmthValue }
): { [P in K]: { [P in SomeEnum]: () => string } };

而不是跟踪整个 S 类型参数,我们只跟踪它的键 K,对于每个值,我们只使用 SmthValue(与您的 Smth[string]).由于 SmthValue 是一个特定的类型,如果你传入一个带有额外键的对象字面量,K 类型将不知道它们,因此你将丢弃这些信息。这会触发过多的 属性 检查警告:

f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    a: "zzz",
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // error!
    // ~~~~~~~~~~ 
    // Object literal may only specify known properties
  }
})

但是您有一个 return 类型用于 f(),它关心 obj 属性 值和键的细节。所以这对你不起作用。


在我们需要保持 S 的一般情况下,我们可以编写一个类型函数,通过查找额外的属性并将它们的值类型映射到 never 来模拟精确类型。有关详细信息,请参阅 microsoft/TypeScript#12936 上的 this comment。我是这样写的:

declare function f<S extends Record<keyof S, SmthValue>>(obj: ProhibitExtraKeys<S>): {
  [K in keyof S]: { [P in SomeEnum]: S[K] extends { args: any } ? () => string : string } }

当您调用 f(something) 时,编译器会将 S 推断为 typeof something,然后对照 ProhibitExtraKeys<S> 进行检查。如果 S 可分配给 ProhibitExtraKeys<S> 那么一切都很好。如果 S 不能 分配给 ProhibitExtraKeys<S> 那么你会得到一个编译器警告。

所以这是 ProhibitExtraKeys<S>:

type ProhibitExtraKeys<S> = {
  [K in keyof S]: {
    [P in keyof S[K]]: P extends (keyof SmthValue) | `${keyof SmthValue}` ? S[K][P] : never
  }
};

它遍历 S 的每个 属性 键 K(可以是任何东西),然后遍历每个子 属性 键 P S[K] 的值,它将 属性 值映射到自身或 never,具体取决于是否需要该键 P

预期的键是 (keyof SmthValue) | `${keyof SmthValue}`。如果您没有 enum 而是使用 "a" | "b",我将只使用 keyof SmthValue,其计算结果为 "args" | "a" | "b",我们就完成了。但是 keyof SmthValue"args" | SomeEnum。不幸的是,enum 值有点奇怪。这是一个错误:

const x: SomeEnum = "a" // error!
// Type '"a"' is not assignable to type 'SomeEnum'

所以除非你想禁止P成为"a""b",否则你需要获取SomeEnum的字符串值。你可以用 template literal types:

type AcceptableKeys = (keyof SmthValue) | `${keyof SmthValue}`
// type AcceptableKeys = SomeEnum | "args" | "a" | "b"

现在我们将接受 "args""a" | "b" 甚至 SomeEnum。让我们看看它是否有效:

const t = f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    [SomeEnum.a]: "zzz", // <-- okay too
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // error!
    //~~~~
    //Type 'string' is not assignable to type 'never'
  }
})

好了。输入输出就是你想要的,我想。

Playground link to code