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