定义一个不允许并集字符串的 "extends string" 类型

Define an "extends string" type that doesn't allow union of strings

我有这个功能

function something<T extends string>(): void {
  // ...
}

我想将 T 限制为单个字符串,而不是字符串的并集:

something<'stringOne'>(); // I want this to be allowed
something<'stringOne' | 'stringTwo'>(); // I DON'T want this to be allowed

有办法吗?

这是一种处理方法:

type NotAUnion<T> = [T] extends [infer U] ? _NotAUnion<U, U> : never;
type _NotAUnion<T, U> = U extends any ? [T] extends [U] ? unknown : never : never;

function something<T extends string & NotAUnion<T>>(): void {
    // ...
}

something<'stringOne'>(); // okay
something<'stringOne' | 'stringTwo'>(); // error

您可以验证它是否有效。


NotAUnion<T>的思路是,如果T是一个union type, NotAUnion<T> should resolve to the never type (TypeScript's bottom type; nothing other than never itself is assignable to never); otherwise, if T is not a union type, NotAunion<T> should resolve to the unknown type (TypeScript's top type;每种类型都可以分配给 unknown).

constraint T extends string & NotAUnion<T> is a recursive constraint, also known as F-bounded quantification,要让编译器认为这是可以接受的,这是一件棘手的事情。如果您不小心,您可能会收到有关循环类型定义的警告。我 以另一种方式解决这个问题,但是您的示例没有任何其他使用 T 的地方(通常是 doSomething<T>(t: T): void 其中 T 显示为参数类型或其他东西),所以我保留了一个版本,其中 T extends XXX 是表达约束的唯一方式。

无论如何,T extends string & NotAUnion<T> 表达了 T 必须可分配给 string 而不能是一个联合的想法:如果 T 是一个联合,那么 string & NotAUnion<T> 将解析为 string & never,后者解析为 never,因此将违反 T extends never。另一方面,如果 T 不是联合,则 string & NotAUnion<T> 将解析为 string & unknown,后者解析为 string,因此 T extends string 将得到满足如果 T 可分配给 string.

这为您提供了 "stringOne""stringOne" | "stringTwo" 所需的行为。它还将接受 string 本身,这不是联合。如果您想禁止这样做,您可以使用更复杂的约束来这样做,但我认为这超出了所问问题的范围(因为它不是指定的用例之一)。


所以这里唯一需要解释的就是NotAUnion<T>是如何工作的。一般的想法是 NotAUnion<T> 将检查 T 是否可以单独分配给 T 的所有联合成员。如果是这样,那么 T 就不是联合(因为它只有一个成员)。如果不是,则 T 是联合。

这里的做法是先用conditional type inference with infer to copy T into another type parameter U. By wrapping this in a single-element tuple, we are preventing this from being a distributive conditional type。所以在 [T] extends [infer U] ? ...T,U... : never 中,TU 都是相同的类型。

所以 [T] extends [infer U] ? _NotAUnion<U, U> : never 本质上与 _NotAUnion<T, T> 相同,除了前者会使编译器抱怨循环。所以 T extends _NotAUnion<T, T> 被视为非法循环,但 T extends NotAUnion<T> 不是。从 T 复制到 U 然后检查 U 似乎是这里的重要部分。

无论如何,然后 _NotAUnion<T, U> 类型有意 分布 跨联合成员的类型函数 U,通过编写分配条件类型 U extends any ? ..,T,U... : never.在该类型函数中,T 将是原始的 T 类型,而 U 将是 T 的每个独立联合成员。因此 T"stringOne" | "stringTwo",那么 U 将依次是 "stringOne""stringTwo"

最后,真正的比较:[T] extends [U] ? unknown : never。这是另一种 non-distributive 类型(我们不想将 T 分成几块)。如果 T 不是并集,那么最终会变成类似于 "stringOne" extends "stringOne" 的结果,这将导致 unknown。但是如果 T 是一个联合,那么这将变成 ("stringOne" | "stringTwo") extends "stringOne",这是错误的,所以你得到 never。它还会检查 ("stringOne" | "stringTwo") extends "stringTwo",这与 false 一样。

好了。显然,这有点令人费解。还有其他方法可以编写这样的函数,但它们同样或更奇怪。 microsoft/TypeScript#34504 中提到了 NotAUnion<T> 的稍微不那么复杂的 one-liner 版本,但它似乎依赖于随 TypeScript 版本而变化的未指定行为。我并没有希望 one-liner 版本总是像我在这里展示的 two-line 版本那样工作,而是在这里使用更可靠的 two-line 版本。

Playground link to code