Sasha
Sasha

Reputation: 6466

Infer type of constructed object

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions