为具有所有可选属性的接口制作类型保护

Making a type guard for an interface with all optional properties

我有一个函数 makeMergedState,它接受一个对象或一个类型为 ICustomState 的数组。

函数包含条件语句,具体取决于输入是有效的 ICustomState 还是 ICustomState[]。如果输入是错误类型转换的无效对象,我希望函数抛出。

这是我要成功的测试用例:

it("throws on invalid input", () => {
  expect(() => makeMergedState({ test: "" } as ICustomState)).toThrow();
});

ICustomState 是一个仅包含可选属性的 TypeScript 接口。我可以用这样的函数来保护数组: const isCustomStateArray = (p: any): p is ICustomState[] => !!p[0];

但是,我找不到制作等效 isCustomState 类型保护的方法,我认为这是类型保护如何与类型系统一起工作的限制。

根据 this GitHub issue, it's possible to work around this limitation with tag types,但我不确定如何。

非常感谢任何建议。

编辑:Codesandbox example

goes over why it's not straightforward to automate the runtime type guarding of compile time interfaces (i.e., type erasure), and what your options are (i.e., code generation as in typescript-is, classes and decorators as in json2typescript, or schema objects which can be used to generate both type guards and interfaces, as in io-ts).

以防万一,我已经将该问题的代码示例翻译成您的案例。这是编写生成类型保护和接口的代码的一种可能方法。您的架构库可能如下所示:

namespace G {
  export type Guard<T> = (x: any) => x is T;
  export type Guarded<T extends Guard<any>> = T extends Guard<infer V> ? V : never;
  const primitiveGuard = <T>(typeOf: string) => (x: any): x is T => typeof x === typeOf;
  export const gString = primitiveGuard<string>("string");
  export const gNumber = primitiveGuard<number>("number");
  export const gBoolean = primitiveGuard<boolean>("boolean");
  export const gNull = (x: any): x is null => x === null;
  export const gObject =
    <T extends object>(propGuardObj: { [K in keyof T]: Guard<T[K]> }) =>
      (x: any): x is T => typeof x === "object" && x !== null &&
        (Object.keys(propGuardObj) as Array<keyof T>).
          every(k => (k in x) && propGuardObj[k](x[k]));
  export const gPartial =
    <T extends object>(propGuardObj: { [K in keyof T]: Guard<T[K]> }) =>
      (x: any): x is { [K in keyof T]?: T[K] } => typeof x === "object" && x !== null &&
        (Object.keys(propGuardObj) as Array<keyof T>).
          every(k => !(k in x) || typeof x[k] === "undefined" || propGuardObj[k](x[k]));
  export const gArray =
    <T>(elemGuard: Guard<T>) => (x: any): x is Array<T> => Array.isArray(x) &&
      x.every(el => elemGuard(el));
  export const gUnion = <T, U>(tGuard: Guard<T>, uGuard: Guard<U>) =>
    (x: any): x is T | U => tGuard(x) || uGuard(x);
  export const gIntersection = <T, U>(tGuard: Guard<T>, uGuard: Guard<U>) =>
    (x: any): x is T & U => tGuard(x) && uGuard(x);
}

据此我们可以构建您的 IExample1 守卫和界面:

const _isExample1 = G.gObject({
  a: G.gNumber,
  b: G.gNumber,
  c: G.gNumber
});
interface IExample1 extends G.Guarded<typeof _isExample1> { }
const isExample1: G.Guard<IExample1> = _isExample1;

如果您查看 _isExample1,您会发现它看起来有点像 {a: number; b: number; c: number},如果您查看 IExample1,它将具有这些属性。注意 gObject 守卫不关心 extra 属性。值 {a: 1, b: 2, c: 3, d: 4} 将是有效的 IExample1;这很好,因为 TypeScript 中的对象类型不是 exact。如果你希望你的类型保护强制没有额外的属性,你可以更改 gObject 的实现(或者制作 gExactObject 或其他东西)。

然后我们构建ICustomState的守卫和接口:

const _isCustomState = G.gPartial({
  example1: isExample1,
  e: G.gString,
  f: G.gBoolean
});
interface ICustomState extends G.Guarded<typeof _isCustomState> { }
const isCustomState: G.Guard<ICustomState> = _isCustomState;

在这里,我们使用 gPartial 使对象仅具有可选属性,如您的问题所示。请注意 gPartial 的守卫检查候选对象,并且仅在键为 present 且类型错误时才拒绝对象。如果密钥丢失或 undefined,那没关系,因为这是可选的 属性 的意思。和 gObject 一样,gPartial 不关心额外的属性。

当我查看您的 codesandbox 代码时,我发现如果存在任何 属性 键,您将返回 true,否则将返回 false,但这不是正确的测试。没有属性的对象 {} 可以分配给具有所有可选属性的对象类型,因此您不需要任何属性。单独存在密钥并不算数,因为对象 {e: 1} 不应分配给 {e?: string}。您需要检查候选对象中存在的所有属性,如果任何属性类型错误,则拒绝它。

(注意:如果您有一个具有一些可选属性和一些必需属性的对象,您可以使用像 G.gIntersection(G.gObject({a: G.gString}), G.gObject({b: G.gNumber})) 这样的交集,它会保护 {a: string} & {b?: number}{a: string, b?: number}.)

最后是你的 ICustomState[] 守卫:

const isCustomStateArray = G.gArray(isCustomState);

让我们测试一下 CustomState 守卫,看看它的行为如何:

function testCustomState(json: string) {
  console.log(
    json + " " + (isCustomState(JSON.parse(json)) ? "IS" : "is NOT") + " a CustomState"
  );
}
testCustomState(JSON.stringify({})); // IS a CustomState
testCustomState(JSON.stringify({ e: "" })); // IS a CustomState
testCustomState(JSON.stringify({ e: 1 })); // is NOT a CustomState
testCustomState(JSON.stringify({ example1: { a: 1, b: 2, c: 3 } })); // IS a CustomState
testCustomState(JSON.stringify({ w: "", f: true })); // IS a CustomState

我觉得这一切都很好。唯一失败的示例是 {e:1},因为它的 e 属性 是错误的类型(number 而不是 string | undefined)。


无论如何,希望这对您有所帮助;祝你好运!

Playground link to code