Typing a function based on a JSON schema passed as argument

Viewed 1199

I have a factory function createF that takes as input a JSON schema and outputs a function f that returns object that fits this schema, it looks like:

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

f1 and f2 always return objects that are shaped like T1 and T2 respectively, but they are not typed: createF is not written so that TS infers the right types to f1 and f2. Would that be possible to rewrite createF so that it does? If yes, how?

I know that it is possible to have a return type that depends on parameters by using function overloading, but in my case all the possible inputs are all JSON schemas and I don't know how to extend the function overloading solution to this case.

Currently, I use json-schema-to-typescript to generate at compile time types around the functions created by createF, but this is not ideal.


A bit of context to avoid the 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 is to not do code generation.

2 Answers

I'd say this looks like a lot of work depending on how much you want the compiler to be able to do for you. I'm not sure if there's an existing set of TS typings for json schema that are rich enough to represent the relationship from schema to output type, so you might have to build some yourself. The following is a sketch specifically tailored around your f1 and f2 examples; other use cases would probably require some modifications/extensions to the code presented here, and there are undoubtedly edge cases where things don't go the way you'd want. The point of the code I will present is to show a general approach, and not a fully baked solution for arbitrary json schemas.


Here's one possible definition for Schema, the type corresponding to json schema objects:

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

A Schema has a type property of some union of string literal types, and if that type is object, then it also has a properties property which is a mapping of keys to other Schema objects, and it might have a required property which is an array of key names.

Translation of a Schema to a type can be done with a conditional type. The interesting part is the object type, which takes up most of the complexity of the code below:

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>

For an object type, SchemaToType looks up the properties and required properties, and produce an object type with keys from properties and values that recursively apply SchemaToType to its properties. This starts off as completely optional, but we use the required property keys and turn the all-optional object into one where those keys are required. There's a lot of utility types being used there: Pick, Omit, Extract, Required, etc. Writing out how it works in detail would take a long time, but the point is you can programmatically convert a subtype of Schema to a type.


Now we give createF the following typing:

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

And test it out.... but first, note that the compiler would normally widen your schema object types too much to be useful. If I wrote this:

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

the compiler would infer it to be this type:

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

Oops, the compiler has forgotten stuff we care about: we need "object" and "a" and "number", not string! So in what follows I will be using const assertions to ask the compiler to keep the inferred type of the passed-in schema objects as narrow as possible:

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

That as const makes a lot of difference:

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

That type has enough detail to do our transformation now.... so let's test it:

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;
} */

The type of t1 is inferred to be number, and the type of t2 is inferred to be {[x: string]: any; a: number' b?: string | undefined }. These are essentially the same as your T1 and T2 types... yay!


So that completes the demonstration. As I said above, be careful of additional use cases and edge cases. Perhaps you'll make progress with this sort of approach, or perhaps in the end you'll find that using the type system for this is too fragile and ugly and that the original code generation solution is a better fit for what you need. Good luck either way!

Playground link to code

This seems like a trivial problem for generics. I'm not exactly sure what your createF function body will look like, but you can see with the use of the generic <T> that the type of the schema argument can be preserved and used to determine the type of the returned function. You don't even need to change the way you're calling createF(), just the function declaration itself, and not even very much:

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 will now infer what the type of the returned function is based on the argument you passed to createF():

enter image description here

Generics are sooooort of like declaring your type to have arguments, similar to a traditional function would. Like a function, the "arguments" (or "generics") don't have a value (or type) until you declare a value of that type.

Related