繁体   English   中英

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

[英]Checking validity of string literal union type at runtime?

我有一个简单的联合类型的字符串文字,需要检查它的有效性,因为 FFI 调用“正常”Javascript。有没有办法确保某个变量在运行时是任何这些文字字符串的实例? 类似的东西

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

从 Typescript 2.1 开始,您可以使用keyof运算符以keyof方式执行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!
}

从 Typescript 3.8.3 开始,对此没有明确的最佳实践。 似乎有三种不依赖于外部库的解决方案。 在所有情况下,您都需要将字符串存储在运行时可用的对象中(例如数组)。

对于这些示例,假设我们需要一个函数来在运行时验证字符串是否是任何规范的绵羊名称,我们都知道这些名称是Capn FriskyMr. SnugsLambchop 这里有三种方法可以让 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);

优点:简单,容易理解。

缺点:脆弱。 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);

PRO:更可重用,稍微不那么脆弱,可以说更具可读性。

CON:打字稿仍然只是相信你的话。 对于如此简单的事情,似乎有很多代码。

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);

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

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


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

Sticklers 会正确地指出,在这里使用数组是低效的。 您可以通过将sheepNames数组转换为O(1) 查找的集合来优化这些解决方案。 如果您正在处理数以千计的潜在绵羊名称(或其他名称),那么值得。

如果您希望能够在运行时检查程序中的多个字符串联合定义,则可以使用通用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"定义字符串联合一样"orc" | "human" | "night elf" | "undead" "orc" | "human" | "night elf" | "undead" "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 库,它提供了类似的功能,用于在 TypeScript 中定义几乎所有类型并自动获取运行时类型检查器。 对于这种特定情况,它会更冗长一些,但如果您需要更灵活的东西,请考虑检查一下。

示例定义

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

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

示例用法与上面相同。

您可以使用enum ,然后检查 Enum 中的字符串

export enum Decisions {
    approve = 'approve',
    reject = 'reject'
}

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

if (decision in Decisions) {
  // valid
}

您可以使用“数组优先”解决方案来创建字符串文字并像往常一样使用它。 并同时使用 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

using 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";
}

基于@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从我们的值数组构建的方式保持一致。

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

一件好事是每次向联合添加/删除较新的类型时,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
   
}

这是我的建议:

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性能更高

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

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);
}

我也有一个类似的要求,我必须断言所有允许的查询参数,并且在同一类型中希望具有所有允许参数的Union类型的类型保护

所以基本上以下是我的要求

  1. 方法应该是通用的,并且可以与具有不同允许参数集的 API 一起使用。 就像在一个 API 中,我可以启用online|offline ,而在另一种情况下,我可以允许full|partial
  2. 如果参数不匹配,它应该抛出异常。 由于我在Express应用程序中使用它,因此所有 API 参数都应在运行时进行验证
  3. 我也喜欢遵循 DRY 并且不希望出现允许的参数因实现而异的情况
  4. 显然不依赖外部库

我的解决方案满足以上所有条件

export function assertUnion<T extends string>(
    param: string,
    allowedValues: ReadonlyArray<T>
): param is T {
    if (!allowedValues.includes(param as T)) {
        throw new Error("Wrong value");
    }
    return true;
}

用法可以像下面这样

if (
    !assertUnion<"online" | "offline">(param, ["online", "offline"])
) {
    return;
}
console.log(param) // type of param will be "online" | "offline"

在这里,我们似乎将允许的类型定义了两次,一次作为Union类型,一次在数组中,但是由于实用程序的定义,您可以在数组中传递任何额外的参数,所以基本上您的Union类型定义将是单一来源真相

我真的很喜欢以某种方式使用param is typeof allowedValues[number]但 Typescript const assestion仅适用于数组文字而不适用于数组参数

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM