【问题标题】:Typing a function based on a JSON schema passed as argument基于作为参数传递的 JSON 模式键入函数
【发布时间】:2020-09-03 15:32:24
【问题描述】:

我有一个工厂函数 createF,它将 JSON 模式作为输入并输出一个函数 f,该函数返回适合此模式的对象,它看起来像:

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 总是 分别返回形状类似于 T1T2 的对象,但它们没有被键入:createF 没有被写入,因此 TS 推断f1f2 的正确类型。是否有可能重写createF 以便它这样做?如果是,怎么做?

我知道 have a return type that depends on parameters 可以使用函数重载,但在我的情况下,所有可能的输入都是 JSON 模式,我不知道如何将函数重载解决方案扩展到这种情况。

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


避免XY problem 的一些上下文:我实际上正在构建oa-client,这是一个基于OpenAPI 规范创建帮助器的库,其中包含模式。在运行时,创建的助手只接受和返回模式中定义的对象;但是在 TS 层上没有类型 - 我必须使用模式来使用节点脚本编写 TS,这并不理想,特别是因为 oa-client 的目标是不进行代码生成。

【问题讨论】:

    标签: javascript json typescript jsonschema


    【解决方案1】:

    这对于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() 的参数推断返回函数的类型:

    泛型就像声明你的类型有参数一样,类似于传统的函数。与函数一样,“参数”(或“泛型”)在您声明该类型的值之前没有值(或类型)。

    【讨论】:

    • 我知道泛型 ;) 感谢您的努力,但没有回答,您的解决方案类型 f1() =&gt; { type: string } 但问题精确到 () =&gt; T1 的预期类型,即 @987654334 @
    • 这就是全部困难 - 如何仅使用 TS 将 JSON 模式转换为类型?或者如何潜在地做到这一点?
    • 你是对的,我没有正确解决你的问题,对此感到抱歉。我能想到的最接近的事情是 PropTypes 如何有一个实用程序类型来从 PropTypes 定义中推断 TS 类型。也许类似的东西? dev.to/busypeoples/…
    【解决方案2】:

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


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

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

    一个Schema 有一个type 属性,属于string literal types 的某个联合,如果typeobject,那么它还有一个properties 属性,它是键到其他@ 的映射987654335@ 对象,它可能有一个 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,值递归地将SchemaToType 应用于其属性。这开始是完全可选的,但我们使用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

    【讨论】:

    • 太棒了!条件类型和 const 断言(我都不知道)的巧妙使用正是我所需要的
    猜你喜欢
    • 2012-06-07
    • 2010-11-13
    • 2020-05-29
    • 2013-07-13
    • 2023-03-26
    • 1970-01-01
    • 2012-06-12
    • 2023-03-10
    • 2017-06-14
    相关资源
    最近更新 更多