Reputation: 6466
In my TypeScript app, I'm taking an OpenAPI spec and constructing an example from it. So the spec might look something like this, to simplify:
const spec = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
}
}
Etc. It's much more complicated than that, because OpenAPI also has more complicated "keywords" (oneOf
, anyOf
), as well as array types, and objects/arrays/keywords can be nested within one another.
But fundamentally, any OpenAPI specification for a "schema" can be converted into an example object, including with auto-generated dummy examples. The above would become something like this once I've turned it into an example:
{
firstName: 'Johnbob',
lastName: 'Default example string',
age: 30
}
The question: Is there any way to automatically infer/generate the type of the generated example? I know I could do this:
const example = {
firstName: 'Johnbob',
lastName: 'Default example string',
age: 30
}
// Ideally, { firstName: string, lastName: string, age: number }
type ExampleType = typeof example;
But I want the return of my generate-example functionality to be typed automatically. Currently, it's just throwing its hands up and returning any
.
Its basic structure is that it has a processSchema
function that takes any schema type (whether object, oneOf, or simple integer type), and then recursively runs through it "processing" each child.
Full playground of code here, and the current WIP implementation below:
type PropertyType = "string" | "number" | "integer" | "object" | "boolean" | "array";
type Property =
| BooleanProperty
| NumberProperty
| IntegerProperty
| StringProperty
| ObjectProperty
| ArrayProperty;
interface OneOf {
oneOf: PropertyOrKeyword[];
}
interface AnyOf {
anyOf: PropertyOrKeyword[];
}
type Keyword = OneOf | AnyOf;
type PropertyOrKeyword = Property | Keyword;
type Properties = Record<string, PropertyOrKeyword>;
interface BaseProperty<T> {
type: PropertyType;
enum?: Array<T>;
example?: T;
description?: string;
}
interface BooleanProperty extends BaseProperty<boolean> {
type: "boolean";
}
interface NumberProperty extends BaseProperty<number> {
type: "number";
minimum?: number;
maximum?: number;
format?: "float";
}
interface IntegerProperty extends BaseProperty<number> {
type: "integer";
minimum?: number;
maximum?: number;
}
type StringFormats =
// OpenAPI built-in formats: https://swagger.io/docs/specification/data-models/data-types/#string
| "date"
| "date-time"
| "password"
| "byte"
| "binary"
// But arbitrary others are accepted
| "uuid"
| "email";
interface StringProperty extends BaseProperty<string> {
type: "string";
format?: StringFormats;
/** A string of a regex pattern **/
pattern?: string;
}
interface ObjectProperty extends BaseProperty<Record<string, Property>> {
type: "object";
properties: Record<string, PropertyOrKeyword>;
required?: string[];
title?: string; // If a schema
additionalProperties?: boolean;
}
interface ArrayProperty extends BaseProperty<Array<any>> {
type: "array";
items: PropertyOrKeyword;
}
class Example {
example;
schema: PropertyOrKeyword;
constructor(schema: PropertyOrKeyword) {
this.schema = schema;
const value = this._processSchema(schema);
this.example = value as typeof value;
}
fullExample(description?: string, externalValue?: string) {
return { value: this.example, description, externalValue };
}
/** Traverses schema and builds an example object from its properties */
_processSchema(schema: PropertyOrKeyword) {
if ("oneOf" in schema) {
return this._processSchema(schema.oneOf[0]);
} else if ("anyOf" in schema) {
return this._processSchema(schema.anyOf[0]);
} else if ("items" in schema) {
return [this._processSchema(schema.items)];
} else if ("type" in schema) {
if (schema.type === "object") {
return Object.entries(schema.properties).reduce(
(obj, [key, val]) => ({
[key]: this._processSchema(val as PropertyOrKeyword),
...obj,
}),
{} as object
);
} else {
if (["integer", "number"].includes(schema.type)) this._processSimpleProperty(schema) as number;
if (schema.type === "boolean") this._processSimpleProperty(schema) as boolean;
if (schema.type === "number") this._processSimpleProperty(schema) as number;
return this._processSimpleProperty(schema) as string;
}
}
}
/** Produces a sensible example for non-object properties */
_processSimpleProperty(
prop: NumberProperty | StringProperty | BooleanProperty | IntegerProperty
): number | boolean | string {
// If an example has been explicitly set, return that
if (prop.example) return prop.example;
// If an enum type, grab the first option as an example
if (prop.enum) return prop.enum[0];
// If a string type with format, return a formatted string
if (prop.type === "string" && prop.format) {
return {
uuid: "asdfa-sdfea-wor13-dscas",
date: "1970-01-14",
["date-time"]: "1970-01-14T05:34:58Z+01:00",
email: "[email protected]",
password: "s00pers33cret",
byte: "0d5b4d43dbf25c433a455d4e736684570e78950d",
binary: "01101001001010100111010100100110100d",
}[prop.format] as string;
}
// Otherwise, return a sensible default
return {
string: "Example string",
integer: 5,
number: 4.5,
boolean: false,
}[prop.type];
}
}
const spec: ObjectProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
}
};
const spec: ObjectProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
favoriteThing: {
oneOf: [{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Beer'
},
liters: {
type: 'integer',
example: 1
}
}
},
{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Movie'
},
lengthInMins: {
type: 'integer',
example: 120
}
}
}
]
}
}
};
console.log(new Example(spec).example)
Upvotes: 1
Views: 201
Reputation: 328342
The main goal here is to write a type function type SchemaToType<T> = ...
which takes a schema type as input and produces the corresponding value type as output. So you want SchemaToType<{type: "string"}>
to be string
, and SchemaToType<{type: "array", items: {type: "number"}}>
to be number[]
, etc.
First we should probably write a Schema
type that all schema types are assignable to. This isn't strictly necessary, and you can do it differently if you want, but here's one way to do it:
type Schema = ObjectSchema | ArraySchema | PrimitiveSchema | OneOf | AnyOf;
type ObjectSchema = {
type: "object",
properties: Record<string, Schema>,
}
type ArraySchema = {
type: "array",
items: Schema,
}
interface PrimMapping {
string: string,
integer: number,
number: number,
// add here
}
type PrimitiveSchema = {
type: keyof PrimMapping,
}
interface OneOf {
oneOf: readonly Schema[]
}
interface AnyOf {
anyOf: readonly Schema[]
}
And of course you can augment this definition with other schema types. You can see that Schema
is a union of specific schema types, some of which (ObjectSchema
, ArraySchema
, OneOf
, and AnyOf
) are recursively defined in terms of Schema
itself.
The reason why I want to allow readonly
arrays is because it is a less restrictive type than regular read-write arrays, and because const
assertions tend to result in readonly tuple types.
Armed with that, here's one way to write SchemaToType<T>
:
type SchemaToType<T extends Schema> =
Schema extends T ? unknown :
T extends OneOf ? SchemaToType<T['oneOf'][number]> :
T extends AnyOf ? SchemaToType<T['anyOf'][number]> :
T extends PrimitiveSchema ? PrimMapping[T['type']] :
T extends ObjectSchema ? {
- readonly [K in keyof T['properties']]: SchemaToType<T['properties'][K]>
} :
T extends ArraySchema ? SchemaToType<T['items']>[] :
never;
This is a recursive conditional type. The idea is that we go through each Schema
union member and convert it into the corresponding value type. Well, the first thing we do is Schema extends T ? unknown :
, which means that if T
turns out to be just Schema
itself, we short-circuit the computation and return the unknown
type. This sort of thing is necessary to avoid circularity warnings, since otherwise SchemaToType<Schema>
would end up recursing into itself forever.
Let's look at the other lines of that type:
T extends OneOf ? SchemaToType<T['oneOf'][number]> :
If T
is a OneOf
, then we need to index into its oneOf
property, which is going to be an array of schemas... so we index further into its number
-valued properties (which gives the array element type). T['oneOf'][number]
will be a union of the schema types in the array. And since SchemaToType
is (mostly) a distributive conditional type, then SchemaToType<T['oneOf'][number]>
will itself be a union of the value types for each element of that union. All of this means that the output type will be a union of all the oneOf
array element value types.
T extends AnyOf ? SchemaToType<T['anyOf'][number]> :
If T
is AnyOf
, we do the same thing as OneOf
. Presumably your schema cares about the difference between "any of" and "one of", but in TypeScript both of these are fairly well represented by a union. If you really care about the difference, you can build an "exclusive" union for OneOf
, using a technique like that shown in this question, but I consider that out of scope here.
T extends PrimitiveSchema ? PrimMapping[T['type']] :
This is the easy one... I wrote PrimMapping
to be a map from type
names to primitive types. So if T
is {type: "integer"}
, then the value type is PrimMapping["integer"]
, which turns out to be number
.
T extends ObjectSchema ? {
- readonly [K in keyof T['properties']]: SchemaToType<T['properties'][K]>
} :
If T
is an ObjectSchema
then we make a mapped type over each key from the properties
property of T
. And finally:
T extends ArraySchema ? SchemaToType<T['items']>[] :
If T
is an ArraySchema
then we make an array of the type corresponding to the items
property of T
.
Let's test it out:
const spec = {
type: 'object',
properties: {
str: {
type: 'string',
},
strOrNum: {
oneOf: [{ type: 'string' }, { type: 'number' }],
example: 30
},
strArr: {
type: 'array',
items: {
type: 'string'
}
}
}
} as const;
type SpecType = SchemaToType<typeof spec>
/* type SpecType = {
str: string;
strOrNum: string | number;
strArr: string[];
} */
Looks good!
When you come to implement some function or class method that takes a value of type Schema
and produces an example, you might have some issues getting the compiler to infer the right type for the schema. If you write the above spec
initializer without the as const
, the compiler will fail to even remember the specific literal types of the type
property and things break:
const specOops = {
type: 'object',
properties: {
str: {
type: 'string',
},
strOrNum: {
oneOf: [{ type: 'string' }, { type: 'number' }],
example: 30
},
strArr: {
type: 'array',
items: {
type: 'string'
}
}
}
} ;
type SpecTypeOops = SchemaToType<typeof specOops> // error!
So if you save things to their own variables you need as const
or something like it.
You can write the function/method in such a way as to make the compiler try to infer narrow types from its input, but it's a bit obnoxious. See microsoft/TypeScript#30680 for a feature request to make it easier. Here's one way to implement it:
type SchemaHelper<T, K extends Types> = Schema & (
{ oneOf: readonly [] | readonly SchemaHelper<T, K>[] } |
{ anyOf: readonly [] | readonly SchemaHelper<T, K>[] } |
{
type: K,
properties?: Record<string, SchemaHelper<T, K>>,
items?: SchemaHelper<T, K>
}
);
declare function schemaToExample<
T extends SchemaHelper<T, K>, K extends Types
>(schema: T): SchemaToType<T>;
All SchemaHelper
does is ask the compiler to pay attention to the type
literal types and the individual array types and values. But yuck.
In any case, let's test it out:
const ex2 = schemaToExample({
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
}
});
/* const ex2: {
firstName: string;
lastName: string;
age: number;
} */
const ex2a = schemaToExample({
oneOf: [{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Beer'
},
liters: {
type: 'integer',
example: 1
}
}
},
{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Movie'
},
lengthInMins: {
type: 'integer',
example: 120
}
}
}
]
}
);
/* const ex2a: {
name: string;
liters: number;
} | {
name: string;
lengthInMins: number;
} */
const ex3 = schemaToExample({
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
favoriteThing: {
oneOf: [{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Beer'
},
liters: {
type: 'integer',
example: 1
}
}
},
{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Movie'
},
lengthInMins: {
type: 'integer',
example: 120
}
}
}
]
}
}
});
/* const ex3: {
firstName: string;
lastName: string;
age: number;
favoriteThing: {
name: string;
liters: number;
} | {
name: string;
lengthInMins: number;
};
} */
Looks good, I think. The output types are the ones you wanted.
Upvotes: 1