根据作为参数传递的 JSON 模式键入函数

Typing a function based on a JSON schema passed as argument

我有一个工厂函数 createF,它将一个 JSON 模式作为输入并输出一个函数 f,returns 对象符合这个模式,它看起来喜欢:

const createF = (schema) => { /* ... */ }

type T1 = number;
const f1: T1 = createF({
  type: 'integer',
});

type T2 = {
  a: number;
  b?: string;
  [key: string]: any;
};
const f2: T2 = createF({
  type: 'object',
  required: ['a'],
  properties: {
    a: { type: 'number' },
    b: { type: 'string' },
  },
});

f1f2 总是 return 形状分别像 T1T2 的对象,但它们没有类型化:createF 没有被写入,以便 TS 将正确的类型推断为 f1f2。可以重写 createF 吗?如果是,如何?

我知道可以 通过使用函数重载,但在我的例子中,所有可能的输入都是 JSON 模式,我不知道如何扩展函数重载这种情况的解决方案。

目前,我使用 json-schema-to-typescript 在编译时围绕 createF 创建的函数生成类型,但这并不理想。


避免XY problem: I am actually building oa-client, a lib that creates a helper based on OpenAPI specs, which contains schemas. At runtime, the created helper only accepts and returns objects defined in the schemas; but on the TS layer there is no types - I have to use the schemas to write TS using node scripts, and that is not ideal, especially since the goal of oa-client的一些上下文是不进行代码生成。

这对于 generics 来说似乎是一个微不足道的问题。我不太确定你的 createF 函数体会是什么样子,但是你可以看到使用泛型 <T> 可以保留和使用 schema 参数的类型以确定返回函数的类型。您甚至不需要更改调用方式 createF(),只需更改函数声明本身,甚至不需要太多:

function createF<T> (schema: T) {
    return () => schema;
}

const f1 = createF({
  type: 'integer',
});
type T1 = number;

const f2 = createF({
  type: 'object',
  required: ['a'],
  properties: {
    a: { type: 'number' },
    b: { type: 'string' },
  },
});
type T2 = {
  a: number;
  b?: string;
  [key: string]: any;
};

TypeScript 现在将根据您传递给 createF() 的参数推断返回函数的类型:

泛型很像声明你的类型有参数,类似于传统的函数。与函数一样,"arguments"(或 "generics")在声明该类型的值之前没有值(或类型)。

我想说这看起来像很多工作,具体取决于您希望编译器能够为您完成多少工作。我不确定 json 模式是否存在一组足够丰富的 TS 类型来表示从模式到输出类型的关系,因此您可能必须自己构建一些。以下是专门针对您的 f1f2 示例量身定制的草图;其他用例可能需要一些 modifications/extensions 到此处提供的代码,并且毫无疑问,在某些边缘情况下,事情不会按照您想要的方式进行。我将展示的代码的重点是展示一种通用方法,而不是针对任意 json 模式的完整解决方案。


这是 Schema 的一种可能定义,对应于 json 模式对象的类型:

type Schema =
  { type: 'number' | 'integer' | 'string' } |
  { 
     type: 'object', 
     required?: readonly PropertyKey[], 
     properties: { [k: string]: Schema } 
  };

A Schema 有一个 type 属性 string literal types 的某个并集,如果那个 typeobject,那么它还有一个 properties 属性,它是键到其他 Schema 对象的映射,它可能有一个 required 属性,它是一个键名数组.

可以使用 conditional typeSchema 转换为类型。有趣的部分是 object 类型,它占据了下面代码的大部分复杂性:

type SchemaToType<S extends Schema> =
  S extends { type: 'number' | 'integer' } ? number :
  S extends { type: 'string' } ? string :
  S extends { type: 'object', properties: infer O, required?: readonly (infer R)[] } ? (
    RequiredKeys<
      { -readonly [K in keyof O]?: O[K] extends Schema ? SchemaToType<O[K]> : never },
      R extends PropertyKey ? R : never
    > & { [key: string]: any }) extends infer U ? { [P in keyof U]: U[P] } : never :
  unknown;

type RequiredKeys<T, K extends PropertyKey> = 
  Required<Pick<T, Extract<keyof T, K>>> & Omit<T, K>

对于对象类型,SchemaToType 查找 propertiesrequired 属性,并使用 properties 中的键和递归应用 properties 的值生成对象类型=30=] 到它的属性。这开始是完全可选的,但我们使用 required 属性 键并将全可选对象变成需要这些键的对象。那里使用了很多 utility typesPickOmitExtractRequired 等。详细写出它的工作原理需要很长时间时间,但重点是您可以通过编程将 Schema 的子类型转换为类型。


现在我们 createF 输入以下内容:

declare function createF<S extends Schema>(s: S): () => SchemaToType<S>;

并对其进行测试....但首先请注意,编译器通常会将您的架构对象类型扩展得太多而无用。如果我这样写:

const tooWideSchema = { 
  type: 'object', required: ["a"], properties: { a: { type: 'number' } } 
};

编译器会推断它是这种类型:

// const tooWideSchema: { 
//   type: string; required: string[]; properties: { a: { type: string; }; }; 
// }

糟糕,编译器忘记了我们关心的东西:我们需要 "object""a""number",而不是 string!因此,在接下来的内容中,我将使用 const assertions 要求编​​译器尽可能缩小传入模式对象的推断类型:

const narrowSchema = { 
  type: 'object', required: ["a"], properties: { a: { type: 'number' } } 
} as const;

as const 有很大不同:

// const narrowSchema: {
//    readonly type: "object";
//    readonly required: readonly ["a"];
//    readonly properties: {
//        readonly a: {
//            readonly type: "number";
//        };
//    };
//}

该类型现在有足够的细节来进行我们的转换....所以让我们测试一下:

const f1 = createF({
  type: 'integer',
} as const);
const t1 = f1();
// const t1: number

const f2 = createF({
  type: 'object',
  required: ["a"],
  properties: {
    a: { type: 'number' },
    b: { type: 'string' },
  },
} as const);
const t2 = f2();
/* const t2: {
    [x: string]: any;
    a: number;
    b?: string | undefined;
} */

t1的类型被推断为numbert2的类型被推断为{[x: string]: any; a: number' b?: string | undefined }。这些基本上与您的 T1T2 类型相同...是的!


这样就完成了演示。正如我上面所说,请注意其他用例和边缘情况。也许您会通过这种方法取得进展,或者最终您会发现为此使用类型系统太脆弱和丑陋,而原始代码生成解决方案更适合您的需要。祝你好运!

Playground link to code