Customizing quicktype

how-to

In this post I’ll show you how to customize quicktype’s output. Suppose we’re building a mobile game in C# that the player can pick up on their Android phone in the morning, continue in their web browser in the afternoon and finish on their iPad in the evening. We have to send the state of the game over the network to a server, which is written in Go, and we’d like to use JSON to serialize and deserialize the state. quicktype already does a great job generating classes with serialization functionality for us, but we have a few extra requirements for the C# code:

  1. Some of the types will represent objects in the game, so quicktype will have to generate them as subclasses of GameObject.

  2. We'd like to specify default values for some properties, such as the hit points of a player or monster. We want quicktype to generate those property assignments.

Out of the box quicktype has neither of those two features, so we'll implement them in this blog post, making quicktype generate customized C# code from a JSON Schema model.

Before writing code to generate other code, we should design the desired generated code by hand to see what works and what doesn't, and to have a more specific idea of what the generated code should look like. Here is the code we want quicktype to generate for us:

public partial class Player : GameObject
{
    public long HitPoints { get; set; } = 100;
    public string Name { get; set; } = "Emmerich";
    public Position Position { get; set; }
}

public partial class Position
{
    public double X { get; set; }
    public double Y { get; set; }
}

As you can see, the Player class is a subclass of GameObject while Position is not. The properties HitPoints and Name in Player also have default values. quicktype can generate neither of those two out of the box, but it's customizable enough to make it happen.

This is the JSON Schema definition that we would like to use as an input to quicktype to generate the sample code above:

{
    "$schema": "http://json-schema.org/draft-06/schema#",
    "$ref": "#/definitions/Player",
    "definitions": {
        "Player": {
            "type": "object",
            "properties": {
                "name": { "type": "string", "default": "Emmerich" },
                "position": { "$ref": "#/definitions/Coordinates" },
                "hitPoints": { "type": "integer", "default": 100 }
            },
            "required": ["name", "position", "hitPoints"],
            "additionalProperties": false,
            "gameObject": true
        },
        "Coordinates": {
            "type": "object",
            "properties": {
                "x": { "type": "number" },
                "y": { "type": "number" }
            },
            "required": ["x", "y"],
            "additionalProperties": false
        }
    }
}

The definition of the Player type has the property gameObject, which obviously isn't part of the JSON Schema standard, and both name and hitPoints have a default property. The latter is part of JSON Schema, but quicktype currently ignores it. We'll change that.

Using quicktype as a library

We will use quicktype as a library and write our own main program that invokes it. You can follow along in this repo.

To start out, we need the quicktype-core NPM package. To invoke quicktype, we need this main function:

import * as fs from "fs";

import { quicktype, InputData, JSONSchemaInput, CSharpTargetLanguage } from "quicktype-core";

async function main(program: string, args: string[]): Promise<void> {
    // Exactly one command line argument allowed, the name of the JSON Schema file.
    if (args.length !== 1) {
        console.error(`Usage: ${program} SCHEMA`);
        process.exit(1);
    }

    // The "InputData" holds all the sources of type information that we give to quicktype.
    const inputData = new InputData();
    const source = { name: "Player", schema: fs.readFileSync(args[0], "utf8") };
    // "JSONSchemaInput" is the class that reads JSON Schema and converts it into quicktype's
    // internal type representation (see https://blog.quicktype.io/under-the-hood/ for a
    // semi-outdated but still useful introduction to the IR).
    // The "source" object is in the form that "JSONSchemaInput" needs.
    await inputData.addSource("schema", source, () => new JSONSchemaInput(undefined));

    // "CSharpTargetLanguage" is the class for basic C# types.  Its subclass
    // "NewtonsoftCSharpTargetLanguage" also produces attributes for Newtonsoft's Json.NET,
    // but we don't need those for our game, so we work with the base class directly.
    // Because it's not specialized we have to give it three arguments, none of which are
    // actually needed in our simple application: The "display name" of the language, an
    // array of names by which we could specify it, were we using quicktype's CLI, and the
    // file extension for the language.
    const lang = new CSharpTargetLanguage("C#", ["csharp"], "cs");

    // What we get back from running "quicktype" is the source code as an array of lines.
    const { lines } = await quicktype({ lang, inputData });

    for (const line of lines) {
        console.log(line);
    }
}

main(process.argv[1], process.argv.slice(2));

When we run this program with our JSON Schema input we get these basic classes:

public partial class Player
{
   public long HitPoints { get; set; }
   public string Name { get; set; }
   public Coordinates Position { get; set; }
}

public partial class Coordinates
{
   public double X { get; set; }
   public double Y { get; set; }
}

Our own target language

Now that we have the basics working, let's build our own subclass of CSharpTargetLanguage and make it do something custom! To be useful, we will have to subclass another class, as well: CSharpRenderer. The TargetLanguage keeps all information about a specific language, such as its name, and what kinds of options its output can be customized with. The Renderer is instantiated by the TargetLanguage with a specific set of options and only lives to produce output once.

Here is our first attempt at a TargetLanguage:

class GameCSharpTargetLanguage extends CSharpTargetLanguage {
    constructor() {
        // In the constructor we call the super constructor with fixed display name,
        // names, and extension, so we don't have to do it when instantiating the class.
        // Our class is not meant to be extensible in turn, so that's okay.
        super("C#", ["csharp"], "cs");
    }

    // "makeRenderer" instantiates our "GameCSharpRenderer".  "cSharpOptions" are the
    // values for the customization options for C#, and "getOptionValues" translates the
    // untyped string values to the typed values that the renderer likes.
    protected makeRenderer(renderContext: RenderContext, untypedOptionValues: { [name: string]: any }): CSharpRenderer {
        return new GameCSharpRenderer(this, renderContext, getOptionValues(cSharpOptions, untypedOptionValues));
    }
}

class GameCSharpRenderer extends CSharpRenderer {
    // We override the "superclassForType" method to make "GameObject" the superclass of all
    // class types.  We need to do the check for "ClassType" because quicktype also generates
    // classes for union types which we don't want to customize.
    protected superclassForType(t: Type): Sourcelike | undefined {
        if (t instanceof ClassType) {
            return "GameObject";
        }
        return undefined;
    }
}

To use the GameCSharpTargetLanguage we have to instantiate it in main:

const lang = new GameCSharpTargetLanguage();

Selective subclassing

To pass the information of whether a type is a game object from the JSON Schema to the renderer we need a feature of quicktype called "type attributes". A type attribute is a piece of information that can be attached to a type. For example, quicktype uses type attributes to store descriptions and potential names for types, and as we’ll see, they can also be used to extend code generation. Type attribute are opaque to the core systems of quicktype: quicktype passes them through, but otherwise leaves them alone, with the exception of invoking a few basic operations that all type attributes must support.

We will define a new kind of type attribute to indicate whether a type is a game object, and we’ll call it GameObjectTypeAttributeKind:

class GameObjectTypeAttributeKind extends TypeAttributeKind<boolean> {
   constructor() {
       // This name is only used for debugging purposes.
       super("gameObject");
   }

   // When two or more classes are combined, such as in a "oneOf" schema, the
   // resulting class is a game object if at least one of the constituent
   // classes is a game object.
   combine(attrs: boolean[]): boolean {
       return attrs.some(x => x);
   }

   // Type attributes are made inferred in cases where the given type
   // participates in a union with other non-class types, for example.  In
   // those cases, the union type does not get the attribute at all.
   makeInferred(_: boolean): undefined {
       return undefined;
   }

   // For debugging purposes only.  It shows up when quicktype is run with
   // with the "debugPrintGraph" option.
   stringify(isGameObject: boolean): string {
       return isGameObject.toString();
   }
}

const gameObjectTypeAttributeKind = new GameObjectTypeAttributeKind();

Our new attribute kind extends TypeAttributeKind<boolean>, which means that the value of one such attribute is just a boolean. All we need to know, after all, is whether—true—or not—false—a type is a game object. Notable things about the methods we override:

  • makeInferred is currently used only for naming types. It has potential other uses, but we won't get into it here. You'll almost always want to return undefined here.

  • quicktype sometimes needs to combine types with each other that are separate in the JSON Schema. For example, we can't (yet) process unions of different class types, so when you try to create one with oneOf or anyOf, the two class types get combined into one, containing the union of the original classes' properties. What happens to those two classes' type attributes is that they are combined with the method combine. In our case, if any of the constituent types represent a game object, we'll make the resulting type represent one, too.

Now that we have our type attribute kind, how do we get the actual attributes onto the types? We need an attribute producer, which is a function that the JSON Schema input code calls on each schema type, and which can return one or more type attributes, or undefined if it doesn't apply to that type. Here's the producer for our game object attribute:

// "schema" is the JSON object in the schema for the type it's being applied to.
// In the case of our "Player" type, that would be the object at "definitions.Player"
// in the schema.

// "canonicalRef" is the location in the schema of that type.  We only use it in an
// error message here.

// "types" is a set of JSON type specifiers, such as "object", "string", or
// "boolean".  The reason it's a set and not just a single one is that one type
// within the schema can specify values of more than one JSON type.  For example,
// { "type": ["string", "boolean"] } is a JSON Schema for "all strings and all
// booleans".
function gameObjectAttributeProducer(
    schema: JSONSchema,
    canonicalRef: Ref,
    types: Set<JSONSchemaType>
): JSONSchemaAttributes | undefined {
    // Booleans are valid JSON Schemas, too: "true" means "all values allowed" and
    // "false" means "no values allowed".  In fact, the "false" for
    // additionalProperties in our schema is a case of the latter.  For that reason,
    // our producer could be called on a boolean, which we have to check for first.
    if (typeof schema !== "object") return undefined;

    // Next we check whether the type we're supposed to produce attributes for even
    // allows objects as values.  If it doesn't, it's not our business, so we
    // return "undefined".
    if (!types.has("object")) return undefined;

    // Now we can finally check whether our type is supposed to be a game object.
    let isGameObject: boolean;
    if (schema.gameObject === undefined) {
        // If it doesn't have the "gameObject" property, it isn't.
        isGameObject = false;
    } else if (typeof schema.gameObject === "boolean") {
        // If it does have it, we make sure it's a boolean and use its value.
        isGameObject = schema.gameObject;
    } else {
        // If it's not a boolean, we throw an exception to let the user know
        // what's what.
        throw new Error(`gameObject is not a boolean in ${canonicalRef}`);
    }

    // Lastly, we generate the type attribute and return it, which requires a bit of
    // ceremony.  A producer is allowed to return more than one type attribute, so to
    // know which attribute corresponds to which attribute kind, it needs to be wrapped
    // in a "Map", which is what "makeAttributes" does.  The "forType" specifies that
    // these attributes go on the actual types specified in the schema.  We won't go
    // into the other possibilities here.
    return { forType: gameObjectTypeAttributeKind.makeAttributes(isGameObject) };
}

To tell the JSONSchemaInput about our attribute producer, we pass it in as the second argument when we instantiate it in main:

await inputData.addSource("schema", source, () =>
    new JSONSchemaInput(undefined, [gameObjectAttributeProducer])
);

That was quite a bit of work, and we're almost done. The last thing we need to do is make our renderer look at the type attribute and only generate the GameObject superclass where we need it. Here's the code required to do that:

protected superclassForType(t: Type): Sourcelike | undefined {
    if (!(t instanceof ClassType)) return undefined;

    // All the type's attributes
    const attributes = t.getAttributes();
    // The game object attribute, or "undefined"
    const isGameObject = gameObjectTypeAttributeKind.tryGetInAttributes(attributes);
    return isGameObject ? "GameObject" : undefined;
}

Apart from the boilerplate, which is a story for another time, this should be self-explanatory. Our code now produces the superclass in the right places!

Default values

The other feature we need are default values, which we'll implement with a second type attribute kind. We will be lazy here: We'll only properly support default values for number, string, and boolean types, and we won't do any error handling for cases that don't work. The way we're implementing it is by holding on to the default value JSON object and just stringifying it into the C# code. The syntax for numbers, strings, and booleans is the same in JSON and C#, which is why this works, mostly.

Here is the type attribute kind, which stores a type's default value as an any:

class DefaultValueTypeAttributeKind extends TypeAttributeKind<any> {
    constructor() {
        super("propertyDefaults");
    }

    combine(attrs: any[]): any {
        // If types with default values are combined, they better have the same
        // default value!  We make sure of that here, and throw an exception if
        // they don't.
        const a = attrs[0];
        for (let i = 1; i < attrs.length; i++) {
            if (a !== attrs[i]) {
                throw new Error("Inconsistent default values");
            }
        }
        return a;
    }

    makeInferred(_: any): undefined {
        return undefined;
    }

    stringify(v: any): string {
        return JSON.stringify(v.toObject());
    }
}

const defaultValueTypeAttributeKind = new DefaultValueTypeAttributeKind();

Again, we'll also need an attribute producer, which is pretty simple in this case:

function propertyDefaultsAttributeProducer(schema: JSONSchema): JSONSchemaAttributes | undefined {
    // booleans are valid JSON Schemas, too, but we won't produce our
    // attribute for them.
    if (typeof schema !== "object") return undefined;

    // Don't make an attribute if there's no "default" property.
    if (typeof schema.default === undefined) return undefined;

    return { forType: defaultValueTypeAttributeKind.makeAttributes(schema.default) };
}

We make sure we're dealing with an object, that it has a default property, and if it does, we just take its value and make an attribute out of it. Of course we also have to pass in the new producer function when we instantiate JSONSchemaInput.

How do we produce the C# code for this? The CSharpRenderer has a method propertyDefinition which returns the code for a single property. What we need to do is override it such that when that property has a default value, there's also an assignment = <default>; at the end:

protected propertyDefinition(p: ClassProperty, name: Name, c: ClassType, jsonName: string): Sourcelike {
    // Call "CSharpRenderer"'s "propertyDefinition" to get the code for the property
    // definition without the default value assignment.
    const originalDefinition = super.propertyDefinition(p, name, c, jsonName);
    // The property's type attributes
    const attributes = p.type.getAttributes();
    const v = defaultValueTypeAttributeKind.tryGetInAttributes(attributes);
    // If we don't have a default value, return the original definition
    if (v === undefined) return originalDefinition;
    // There is a default value, so stringify it and bolt it onto the original code
    // at the end.
    return [originalDefinition, " = ", JSON.stringify(v), ";"];
}

We're done! Check out the finished project on GitHub. If you'd like to apply this to your own use case and need any help whatsoever, please come talk to us on Slack.

Mark

Mark