在运行时检查字符串字面量联合类型的有效性?

94

我有一个简单的字符串字面量联合类型,并且由于与“正常”的JavaScript的 FFI 调用需要检查它的有效性。是否有一种方法可以确保某个变量在运行时是这些字面字符串中的任何一个实例?类似于:

type MyStrings = "A" | "B" | "C";
MyStrings.isAssignable("A"); // true
MyStrings.isAssignable("D"); // false
11个回答

74
截至Typescript 3.8.3,目前还没有明确的最佳实践。似乎有三种解决方案不依赖于外部库。在所有情况下,您都需要将字符串存储在运行时可用的对象中(例如数组)。
对于这些示例,请假设我们需要一个函数来验证运行时字符串是否是任何规范化的羊名称,我们都知道它们是Capn Frisky、Mr. Snugs、Lambchop。以下是三种以Typescript编译器可以理解的方式进行操作的方法。
1:类型断言(更简单)
摘下头盔,自己验证类型并使用断言。
const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number]; // "Capn Frisky" | "Mr. Snugs" | "Lambchop"

// This string will be read at runtime: the TS compiler can't know if it's a SheepName.
const unsafeJson = '"Capn Frisky"';

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    // This if statement verifies that `maybeSheepName` is in `sheepNames` so
    // we can feel good about using a type assertion below.
    if (typeof maybeSheepName === 'string' && sheepNames.includes(maybeSheepName)) {
        return (maybeSheepName as SheepName); // type assertion satisfies compiler
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

PRO:简单易懂。

CON:脆弱。Typescript 只是相信你已经充分验证了 maybeSheepName ,如果你不小心移除了这个检查,Typescript 将无法保护你免受错误的影响。

2:自定义类型守卫(更可重用)

这是一个更高级、更通用的类型断言版本,但它仍然只是一种类型断言。

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number];

const unsafeJson = '"Capn Frisky"';

/**
 * Define a custom type guard to assert whether an unknown object is a SheepName.
 */
function isSheepName(maybeSheepName: unknown): maybeSheepName is SheepName {
    return typeof maybeSheepName === 'string' && sheepNames.includes(maybeSheepName);
}

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    if (isSheepName(maybeSheepName)) {
        // Our custom type guard asserts that this is a SheepName so TS is happy.
        return (maybeSheepName as SheepName);
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

优点:更加可重用,稍微不那么脆弱,可能更易读。

缺点:Typescript仍然只是相信你的话。对于如此简单的事情来说,代码量似乎有点多。

3:使用Array.find(最安全,推荐)

如果您(像我一样)不信任自己,这不需要类型断言。

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number];

const unsafeJson = '"Capn Frisky"';

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    const sheepName = sheepNames.find((validName) => validName === maybeSheepName);
    if (sheepName) {
        // `sheepName` comes from the list of `sheepNames` so the compiler is happy.
        return sheepName;
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

优点:不需要类型断言,编译器仍然进行所有验证。对我来说很重要,所以我更喜欢这个解决方案。

缺点:看起来有点奇怪。更难优化性能。


就是这样。您可以合理地选择任何一种策略,或者使用其他人推荐的第三方库。

一些苛刻的人会正确地指出,在这里使用数组是低效的。您可以通过将sheepNames数组转换为一个集合,以实现O(1)查找,并优化这些解决方案。如果您在处理数千个潜在的羊名(或其他任何内容),那么这是值得的。


Array.prototype.indexOf() 在最近的 Node 和一些浏览器中似乎具有 O(1) 的查找。 - jchook
2
第二个示例中的类型保护似乎并没有真正起作用,因为使用了 in,它期望一个索引或属性名。不过使用 Array.prototype.includes 似乎可以解决这个问题。 - Jameel A.
1
@JameelA。是的,抱歉我一直想更新,已经修复了。 - jtschoonhoven
7
测试第二个例子时出现了 Argument of type 'string' is not assignable to parameter of type '"Capn Frisky", "Mr. Snugs", "Lambchop"'. 当我从 sheepNames 数组中移除 as const 时,它可以正常工作。有任何想法我可能遗漏了什么吗? - Hans
@Hans 我也遇到了这个问题 :/ - bitconfused
保留 const,将输入强制转换为 SheepName 类型 sheepNames.includes(maybeSheepName as SheepName); - Leedan Johnson

36
自 Typescript 2.1 开始,您可以使用 keyof 操作符 来实现相反的效果。
思路如下:由于字符串文字类型信息不可用于运行时,您将定义一个仅包含字符串文字为键的普通对象,然后创建该对象键的类型。
示例代码如下:
// Values of this dictionary are irrelevant
const myStrings = {
  A: "",
  B: ""
}

type MyStrings = keyof typeof myStrings;

isMyStrings(x: string): x is MyStrings {
  return myStrings.hasOwnProperty(x);
}

const a: string = "A";
if(isMyStrings(a)){
  // ... Use a as if it were typed MyString from assignment within this block: the TypeScript compiler trusts our duck typing!
}

3
如果创建一个空值对象让你感到不舒服,你也可以使用数组或集合,然后分别检查 x in myStringsmyStrings.has(x) - jtschoonhoven
4
@jtschoonhoven中的x in myStrings实际上不能正常工作,对数组使用in运算符将导致意外的结果。检查'x' in []就能明白我说的是什么了... myStrings.includes(x)是一种方法,或者是经典的myStrings.indexOf(x) > -1。 另外,如果你选择数组解决方案(就像你在答案中已经指出的那样),类型应该是type MyStrings = typeof myStrings[number] - panepeter

21

如果您的程序中有多个字符串联合定义,并希望在运行时进行检查,您可以使用通用的StringUnion函数来生成它们的静态类型和类型检查方法。

通用支持函数

// TypeScript will infer a string union type from the literal values passed to
// this function. Without `extends string`, it would instead generalize them
// to the common string type. 
export const StringUnion = <UnionType extends string>(...values: UnionType[]) => {
  Object.freeze(values);
  const valueSet: Set<string> = new Set(values);

  const guard = (value: string): value is UnionType => {
    return valueSet.has(value);
  };

  const check = (value: string): UnionType => {
    if (!guard(value)) {
      const actual = JSON.stringify(value);
      const expected = values.map(s => JSON.stringify(s)).join(' | ');
      throw new TypeError(`Value '${actual}' is not assignable to type '${expected}'.`);
    }
    return value;
  };

  const unionNamespace = {guard, check, values};
  return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType});
};

示例定义

我们还需要一行样板代码来提取生成的类型并将其定义与命名空间对象合并。 如果此定义被导出并导入到另一个模块中,它们将自动获得合并后的定义; 消费者不需要重新提取类型。

const Race = StringUnion(
  "orc",
  "human",
  "night elf",
  "undead",
);
type Race = typeof Race.type;

使用示例

在编译时,Race 类型的工作方式与我们通常使用 "orc" | "human" | "night elf" | "undead" 定义字符串联合类型的方式相同。此外,我们还有一个 .guard(...) 函数用于返回一个值是否是该联合类型成员的布尔值,可以用作类型保护,以及一个 .check(...) 函数,如果传递的值有效,则返回该值,否则会抛出一个 TypeError 异常。

let r: Race;
const zerg = "zerg";

// Compile-time error:
// error TS2322: Type '"zerg"' is not assignable to type '"orc" | "human" | "night elf" | "undead"'.
r = zerg;

// Run-time error:
// TypeError: Value '"zerg"' is not assignable to type '"orc" | "human" | "night elf" | "undead"'.
r = Race.check(zerg);

// Not executed:
if (Race.guard(zerg)) {
  r = zerg;
}

更为通用的解决方案:runtypes

这种方法基于runtypes库,它提供了类似的函数来定义TypeScript中几乎任何类型,并自动获取运行时类型检查器。对于这种特定情况,它可能会稍微冗长一些,但如果您需要更灵活的东西,请考虑查看它。

示例定义

import {Union, Literal, Static} from 'runtypes';

const Race = Union(
  Literal('orc'),
  Literal('human'),
  Literal('night elf'),
  Literal('undead'),
);
type Race = Static<typeof Race>;

使用示例与上面相同。


9
您可以使用 enum,然后检查字符串是否在枚举中。
export enum Decisions {
    approve = 'approve',
    reject = 'reject'
}

export type DecisionsTypeUnion =
    Decisions.approve |
    Decisions.reject;

if (decision in Decisions) {
  // valid
}

3
问题在于,在有效块中,决策类型没有受到语言服务的限制。 - Ozymandias
2
确实。Typescript编译器无法理解decision在有效块中的类型为Decisions。您需要添加类型断言。 - jtschoonhoven

8

使用“数组第一个”解决方案,同时创建字符串字面量并使用Array.includes():

const MyStringsArray = ["A", "B", "C"] as const;
MyStringsArray.includes("A" as any); // true
MyStringsArray.includes("D" as any); // false

type MyStrings = typeof MyStringsArray[number];
let test: MyStrings;

test = "A"; // OK
test = "D"; // compile error

4

type 的使用仅仅是类型别名,并且它不会出现在编译后的 JavaScript 代码中。因此,你不能真正地执行以下操作:

MyStrings.isAssignable("A");

您可以通过以下方式使用它:

type MyStrings = "A" | "B" | "C";

let myString: MyStrings = getString();
switch (myString) {
    case "A":
        ...
        break;

    case "B":
        ...
        break;

    case "C":
        ...
        break;

    default:
        throw new Error("can only receive A, B or C")
}

关于你提出的关于 isAssignable 的问题,你可以:

function isAssignable(str: MyStrings): boolean {
    return str === "A" || str === "B" || str === "C";
}

14
这个方法是有效的,但一旦MyStrings被扩展而未更新相应的isAssignable函数,它就会失效。我希望有一种更少手动干预的解决方案。 - Marcus Riemer
2
你无法做太多事情。正如我所写的,type 部分是严格的 TypeScript 代码,这些信息在生成的 JS 文件中会丢失。你可以使用 enum 来代替 type - Nitzan Tomer
2
如果你愿意付出努力,你可以编写一个工具来生成定义类型和运行时类型检查的TS代码,该代码可以从其他数据源中获取。我曾经参与过一些项目,在这些项目中,REST API是用C#编写的,我们将C#数据合同编译成了TS,包括类型定义和运行时可检查的对象字面量描述类型。显然,所有这些都超出了TS的范围。 - Alan

3

我采用了从联合类型创建新对象类型的方法,并创建了一个虚拟实例。然后可以使用类型保护来检查字符串类型。

这种方法的好处是,每次向联合类型添加/删除新类型时,TS编译器都会提醒更新对象。

type MyStrings = "A" | "B" | "C";
type MyStringsObjectType = {
   [key in MyStrings ] : any
}
export const myStringsDummyObject : MyStringsObjectType = {
   A : "",
   B : "",
   C : "",
}
export const isAssignable = (type: string):type is MyStrings => {
   return (type in myStringsDummyObject)
}

使用方法:

if(isAssignable("A")){  //true
   
}

if(isAssignable("D")){  //false
   
}

2

你不能在类型上调用方法,因为类型在运行时不存在。

MyStrings.isAssignable("A"); // Won't work — `MyStrings` is a string literal

相反,创建可执行的JavaScript代码来验证您的输入。程序员有责任确保函数正常工作。

function isMyString(candidate: string): candidate is MyStrings {
  return ["A", "B", "C"].includes(candidate);
}

更新

根据 @jtschoonhoven 的建议,我们可以创建一个详尽的类型保护程序,检查任何字符串是否属于 MyStrings 中的一种。

首先,创建一个名为 enumerate 的函数,确保使用了 MyStrings 联合类型的所有成员。当联合类型在未来扩展时,它应该中断,提示您更新类型保护程序。

type ValueOf<T> = T[keyof T];

type IncludesEvery<T, U extends T[]> =
  T extends ValueOf<U>
    ? true
    : false;

type WhenIncludesEvery<T, U extends T[]> =
  IncludesEvery<T, U> extends true
    ? U
    : never;

export const enumerate = <T>() =>
  <U extends T[]>(...elements: WhenIncludesEvery<T, U>): U => elements;

新的并且改进的类型保护:
function isMyString(candidate: string): candidate is MyStrings {
  const valid = enumerate<MyStrings>()('A', 'B', 'C');

  return valid.some(value => candidate === value);
}

1
不确定为什么这个被踩了,这是正确的并提供了一个可行的解决方案。虽然我强烈建议在isMyString函数中添加一个检查来验证["A", "B", "C"]是否符合MyStrings,否则两者将危险地分歧。 - jtschoonhoven
1
好的建议。我更新了我的回答。 - Karol Majewski
我可以这样做,但那样它就不会具备功能完善的特点了。例如,如果我向MyStrings中添加另一个字符串,比如说 type MyStrings = "A" | "B" | "C" | "D",类型保护将返回一个错误的负结果。 - Karol Majewski
如果将myStrings定义为Array<MyStrings>类型,那么如果您更新了MyStrings类型而没有更新相应的myStrings值,则编译器会抛出错误。因此,这样做是安全的。 - jtschoonhoven
恐怕它不是这样工作的。游乐场:https://tsplay.dev/YmpgNg - Karol Majewski
显示剩余2条评论

1
基于 @jtschoonhoven 的最安全解决方案,我们可以编写通用工厂来生成解析或验证函数:
const parseUnionFactory = <RawType, T extends RawType>(values: readonly T[]): ((raw: RawType) => T | null) => {
   return (raw: RawType): T => {
       const found = values.find((test) => test === raw)
       if (found) {
           return found
       }
       throw new InvalidUnionValueError(values, raw)
    }
}

使用中:

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const
type SheepName = typeof sheepNames[number]

const parseSheepName = parseUnionFactory(sheepNames)
let imaSheep: SheepName = parseSheepName('Lampchop') // Valid
let thisllThrow: SheepName = parseSheepName('Donatello') // Will throw error

替换示例

这里的弱点在于确保我们的类型parseUnionFactory从值数组中构建的方式保持一致。


1
这是我的建议:
const myFirstStrings = ["A", "B", "C"] as const;
type MyFirst = typeof myFirstStrings[number];

const mySecondStrings =  ["D", "E", "F"] as const;
type MySecond = typeof mySecondStrings[number];

type MyStrings = MyFirst | MySecond;

const myFirstChecker: Set<string> = new Set(myFirstStrings);

function isFirst(name: MyStrings): name is MyFirst {
  return myFirstChecker.has(name);
}

这个解决方案比其他答案建议使用的Array.find更加高效。


1
我认为你可以通过将isFirst作为类型谓词来进行优化。 - Marcus Riemer
1
为什么不呢?这不会改变返回类型,只是为类型系统添加了补充。 - Marcus Riemer
我误解了“类型保护”。稍后我会尝试一下,如果它按预期工作,我会更新答案。感谢您的评论。 - Soldeplata Saketos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接