Runtime type for subclasses

This recipe is useful when you need to revive fields where several subclasses can appear.

An example of this is JSON generated using Gson's TypeAdapter. In fact, this example is based on Gson's RuntimeTypeAdapterFactory

We will use ajvMetadata to demonstrate how to get Modélico to generate complex JSON schemas. This is also an example of a schema with circular references.

class Geometer extends M.Base {
  static innerTypes () {
    return Object.freeze({
      name: string({minLength: 1}),
      // Shape has multiple subclasses
      favouriteShape: _(Shape)
    })
  }
}

The Shape in the inner types can be either a Diamond or a Circle. Let's look at their definitions:

const ShapeType = M.Enum.fromArray(['CIRCLE', 'DIAMOND'])

// We are going to use this metadata in several places, so by reusing it, we
// not only save unnecessary processing, but our generated JSON schema will
// also be more compact.
const greaterThanZero = number({
  exclusiveMinimum: 0
})

const reviver = (k, v) => {
  if (k !== '') {
    return v
  }

  switch (v.type) {
    case ShapeType.CIRCLE().toJSON():
      return new Circle(v)
    case ShapeType.DIAMOND().toJSON():
      return new Diamond(v)
    default:
      throw TypeError('Unsupported or missing shape type in the Shape reviver.')
  }
}

class Shape extends M.Base {
  toJSON () {
    const fields = M.fields(this)
    let type

    // the generated JSON must contain runtime type information for Modélico
    // to be able to revive it later
    switch (this[M.symbols.typeSymbol]()) {
      case Circle:
        type = ShapeType.CIRCLE()
        break
      case Diamond:
        type = ShapeType.DIAMOND()
        break
      default:
        throw TypeError('Unsupported Shape in the toJSON method.')
    }

    return Object.freeze(Object.assign({type}, fields))
  }

  static innerTypes () {
    return Object.freeze({
      // notice the self-reference here and how the subclasses are
      // going to extend Shape's inner types
      relatedShape: maybe(_(Shape))
    })
  }

  static metadata () {
    // We are going to use meta so that M.getSchema can give us a more
    // robust JSON schema. If you don't need that, you could return the
    // baseMetadata directly.
    const baseMetadata = Object.assign({}, base(Shape), {reviver})

    return meta(baseMetadata, {}, {}, () => ({
      anyOf: [
        Circle,
        Diamond
      ].map(x => M.getSchema(base(x), false))
    }))
  }
}
class Circle extends Shape {
  constructor (props) {
    super(Circle, props)
  }

  area () {
    return Math.PI * this.radius() ** 2
  }

  static innerTypes () {
    return Object.freeze(Object.assign({}, super.innerTypes(), {
      radius: greaterThanZero
    }))
  }
}
class Diamond extends Shape {
  constructor (props) {
    super(Diamond, props)
  }

  area () {
    return this.width() * this.height() / 2
  }

  static innerTypes () {
    return Object.freeze(Object.assign({}, super.innerTypes(), {
      width: greaterThanZero
      height: greaterThanZero
    }))
  }
}

With that, we can revive Diamonds

const geometer1 = M.fromJS(Geometer, {
  name: 'Audrey',
  favouriteShape: {
    type: 'DIAMOND',
    width: 8,
    height: 7
  }
})

geometer1.favouriteShape().area() // => 28

as well as Circles:

const geometer2 = M.fromJS(Geometer, {
  name: 'Javier',
  favouriteShape: {
    type: 'CIRCLE',
    radius: 3
  }
})

geometer2.favouriteShape().area() // => Math.PI * 3 ** 2

Serialisation also works for Diamonds and Circles

geometer1.toJS()
geometer2.toJS()

respectively yield:

{
  name: 'Audrey',
  favouriteShape: {
    type: 'DIAMOND',
    relatedShape: null,
    width: 8,
    height: 7
  }
}
{
  name: 'Javier',
  favouriteShape: {
    type: 'CIRCLE',
    relatedShape: null,
    radius: 3
  }
}

Now remember how we have been using ajvMetadata and even enhanced the Shape metadata to account for its subtypes. This is going to allow us to get a very detailed schema that would not be easy to write by hand.

Note: definitions are sequentially named to avoid collisions. In the example below, the definition numbers have gaps because some of them got reserved in case they'd be reused. Short sub-schemas (less than 2 keys and not arrays) are always inlined.

M.getSchema(_(Geometer))

yields:

{
  type: 'object',
  properties: {
    name: {
      $ref: '#/definitions/2'
    },
    favouriteShape: {
      $ref: '#/definitions/3'
    }
  },
  required: ['name', 'favouriteShape'],
  definitions: {
    '2': {
      type: 'string',
      minLength: 1
    },
    '3': {
      anyOf: [
        {
          $ref: '#/definitions/4'
        },
        {
          $ref: '#/definitions/7'
        }
      ]
    },
    '4': {
      type: 'object',
      properties: {
        relatedShape: {
          $ref: '#/definitions/5'
        },
        radius: {
          $ref: '#/definitions/6'
        }
      },
      required: ['radius']
    },
    '5': {
      anyOf: [
        {
          type: 'null'
        },
        {
          $ref: '#/definitions/3'
        }
      ]
    },
    '6': {
      type: 'number',
      exclusiveMinimum: 0
    },
    '7': {
      type: 'object',
      properties: {
        relatedShape: {
          $ref: '#/definitions/5'
        },
        width: {
          $ref: '#/definitions/6'
        },
        height: {
          $ref: '#/definitions/6'
        }
      },
      required: ['width', 'height']
    }
  }
}

Last updated