Page title

Section title

Generating Ruby from JSON Typedef schemas

JSON Type Definition, aka RFC 8927, is an easy-to-learn, standardized way to define a schema for JSON data. You can use JSON Typedef to portably validate data across programming languages, create dummy data, generate code, and more.

This article is about how you can use JSON Typedef to generate Ruby code from schemas. If you’re interested in generating code in other languages, see this article on jtd-codegen. The rest of this article focuses on using jtd-codegen with Ruby in particular.

Generating Ruby with jtd-codegen

As a prerequisite, you need to first install jtd-codegen. Installation instructions are available here.

You can generate Ruby with jtd-codegen using the --ruby-out option, whose value must be a directory that jtd-codegen can generate code into. You’ll also need to pass --ruby-module, indicating the Ruby module jtd-codegen should generate its classes into.

For example, if you have this schema in schemas/user.jtd.json:

{
  "properties": {
    "id": { "type": "string" },
    "createdAt": { "type": "timestamp" },
    "karma": { "type": "int32" },
    "isAdmin": { "type": "boolean" }
  }
}

Then you can generate Ruby code into the src directory by running:

jtd-codegen schemas/user.jtd.json --ruby-out src --ruby-module User

Which will output something like:

📝 Writing Ruby code to: src
📦 Generated Ruby code.
📦     Root schema converted into type: User

And you should see code along these lines in src/user.rb:

# Code generated by jtd-codegen for Ruby v0.1.0

require 'json'
require 'time'

module User

  class User
    attr_accessor :created_at
    attr_accessor :id
    attr_accessor :is_admin
    attr_accessor :karma

    def self.from_json_data(data)
      out = User.new
      out.created_at = User::from_json_data(DateTime, data["createdAt"])
      out.id = User::from_json_data(String, data["id"])
      out.is_admin = User::from_json_data(TrueClass, data["isAdmin"])
      out.karma = User::from_json_data(Integer, data["karma"])
      out
    end

    def to_json_data
      data = {}
      data["createdAt"] = User::to_json_data(created_at)
      data["id"] = User::to_json_data(id)
      data["isAdmin"] = User::to_json_data(is_admin)
      data["karma"] = User::to_json_data(karma)
      data
    end
  end

  private

  def self.from_json_data(type, data)
    if data.nil? || [Object, TrueClass, Integer, Float, String].include?(type)
      data
    elsif type == DateTime
      DateTime.rfc3339(data)
    elsif type.is_a?(Array)
      data.map { |elem| from_json_data(type.first, elem) }
    elsif type.is_a?(Hash)
      data.transform_values { |elem| from_json_data(type.values.first, elem) }
    else
      type.from_json_data(data)
    end
  end

  def self.to_json_data(data)
    if data.nil? || [TrueClass, FalseClass, Integer, Float, String].include?(data.class)
      data
    elsif data.is_a?(DateTime)
      data.rfc3339
    elsif data.is_a?(Array)
      data.map { |elem| to_json_data(elem) }
    elsif data.is_a?(Hash)
      data.transform_values { |elem| to_json_data(elem) }
    else
      data.to_json_data
    end
  end
end

Note: at the time of writing, generated code is not always formatted in a pretty way. If you require pretty-formatted code, it’s recommended that you use a code formatter on jtd-codegen-generated code.

Generating Ruby Signatures with jtd-codegen

Ruby 3 introduces support for rbs, a standardized way to add static type checking to Ruby programs. jtd-codegen supports generating .rbs files for all generated Ruby code.

Just as Ruby code generation accepts --ruby-out and --ruby-module, RBS code generation accepts --ruby-sig-out and --ruby-sig-module. You can generate both at once like so:

jtd-codegen schemas/user.jtd.json \
  --ruby-out src --ruby-module User \
  --ruby-sig-out lib --ruby-sig-module User

At which point, you can use a tool like steep to verify that your code is interacting with the jtd-codegen-generated classes correctly.

Using generated Ruby code

jtd-codegen will always output code into a .rb file inside the directory you specify with --ruby-out; the name of the file is derived from the value of --ruby-module. In the previous example, we outputted code into src/user.rb, so we can import it like so:

require_relative 'user'

The generated Ruby code does not presume a particular JSON implementation. You can use jtd-codegen-generated types with the standard library’s JSON module, or you can use alternatives like oj.

The generated code is JSON-library independent because every generated class has a from_json_data and to_json_data method. from_json_data takes in already-parsed JSON data, such as data returned from JSON.parse, and returns an instance of the type. to_json_data does the reverse, constructing data that you can pass to JSON.generate.

Do not directly pass instances of jtd-codegen-generated types to JSON libraries. You will get the wrong JSON result if you do this. Always pass through from_json_data or to_json_data first.

Here’s an example of you’d use the User class we imported above:

require 'json'

# To read in JSON, assuming you know the JSON is valid, do something like:
input_json = '...'
user = User::User.from_json_data(JSON.parse(input_json))

# To write out JSON, do something like:
output_json = JSON.generate(user.to_json_data)

In the example above, we directly use from_json_data on parsed JSON data. This will only work correctly if the JSON input is valid. Otherwise, you may encounter runtime exceptions, and the types of the class’s attributes may be different from those indicated on their RBS annotations. If you do run into runtime exceptions, these exceptions will be Ruby-specific and low-level.

To prevent this from happening, you should first validate the JSON input using a JTD validation implementation, such as the jtd gem. What you would do is:

  1. Parse the input using your preferred JSON backend.
  2. Validate that the parsed JSON is valid against the schema you generated your types from, using a JTD validation implementation. If there are validation errors, you can return those, because JTD validation errors are standardized and platform-independent.
  3. If the input is valid, then construct an instance of your generated type using its from_json_data method. Pass in the JSON you just validated to from_json_data.

This solution lets you produce portable validation errors and lets you be more deliberate about what inputs you do and don’t accept.

Customizing Ruby output

Ruby code generation supports the following metadata properties shared across all languages supported by jtd-codegen:

  • description customizes the documentation comment to put on a type or property in generated code. For example, this schema:

    {
      "metadata": {
        "description": "A user in our system"
      },
      "properties": {
        "name": {
          "metadata": {
            "description": "The user's name"
          },
          "type": "string"
        },
        "isAdmin": {
          "metadata": {
            "description": "Whether the user is an admin"
          },
          "type": "boolean"
        }
      }
    }
    

    Generates into:

    # A user in our system
    class Docuser
      # Whether the user is an admin
      attr_accessor :is_admin
    
      # The user's name
      attr_accessor :name
    
      def self.from_json_data(data)
        out = Docuser.new
        out.is_admin = User::from_json_data(TrueClass, data["isAdmin"])
        out.name = User::from_json_data(String, data["name"])
        out
      end
    
      def to_json_data
        data = {}
        data["isAdmin"] = User::to_json_data(is_admin)
        data["name"] = User::to_json_data(name)
        data
      end
    end
    
  • enumDescription is like description, but for the members of an enum. The keys of enumDescription should correspond to the values in the schema’s enum, and the values should be descriptions for those values. For example, this schema:

    {
      "metadata": {
        "enumDescription": {
          "PENDING": "The job is waiting to be processed.",
          "IN_PROGRESS": "The job is being processed.",
          "DONE": "The job has been processed."
        }
      },
      "enum": ["PENDING", "IN_PROGRESS", "DONE"]
    }
    

    Generates into:

    class Status
      attr_accessor :value
    
      def initialize(value)
        self.value = value
      end
    
      private_class_method :new
    
      # The job has been processed.
      DONE = new("DONE")
    
      # The job is being processed.
      IN_PROGRESS = new("IN_PROGRESS")
    
      # The job is waiting to be processed.
      PENDING = new("PENDING")
    
      def self.from_json_data(data)
        {
          "DONE" => DONE,
          "IN_PROGRESS" => IN_PROGRESS,
          "PENDING" => PENDING,
        }[data]
      end
    
      def to_json_data
        value
      end
    end
    

Additionally, Ruby code generation supports the following Ruby-specific options:

  • rubyType overrides the type that jtd-codegen should generate. jtd-codegen will not generate any code for schemas with rubyType, and instead use the value of rubyType as-is.

    It is your responsibility to ensure that the value of rubyType is valid code. jtd-codegen will not attempt to validate its value.

    For example, this schema:

    {
      "properties": {
        "name": { "type": "string" },
        "isAdmin": {
          "metadata": {
            "rubyType": "MyCustomType"
          },
          "type": "boolean"
        }
      }
    }
    

    Generates into:

    class OverrideDemo
      attr_accessor :is_admin
      attr_accessor :name
    
      def self.from_json_data(data)
        out = OverrideDemo.new
        out.is_admin = Example::from_json_data(MyCustomType, data["isAdmin"])
        out.name = Example::from_json_data(String, data["name"])
        out
      end
    
      def to_json_data
        data = {}
        data["isAdmin"] = Example::to_json_data(is_admin)
        data["name"] = Example::to_json_data(name)
        data
      end
    end
    

    With a corresponding type signature (if you’re also generating .rbs files):

    class OverrideDemo
      attr_accessor is_admin: MyCustomType
      attr_accessor name: String
    
      def self.from_json_data: (untyped) -> OverrideDemo
      def to_json_data: () -> untyped
    end
    

Generated Ruby code

This section details the sort of Ruby code that jtd-codegen will generate.

Code generated from “Empty” schemas

“Empty” schemas will be converted into a Ruby untyped if you’re using RBS:

{}

Generates into:

# .rb file
class Empty
  attr_accessor :value

  def self.from_json_data(data)
    out = Empty.new
    out.value = Example.from_json_data(Object, data)
    out
  end

  def to_json_data
    Example.to_json_data(value)
  end
end

# .rbs file
class Empty
  attr_accessor value: untyped

  def self.from_json_data: (untyped) -> Empty
  def to_json_data: () -> untyped
end

Note: jtd-codegen had to generate a custom type alias here, which is why the code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or “values” schemas at the top level of a schema, jtd-codegen has to emit type aliases in Ruby. In real-world schemas, this doesn’t happen very often.

Code generated from “Ref” schemas

“Ref” schemas will be converted into a reference to the definition being referred to:

{
  "definitions": {
    "example": { "type": "string" }
  },
  "ref": "example"
}

Generates into:

# .rb file
class Ref
  attr_accessor :value

  def self.from_json_data(data)
    out = Ref.new
    out.value = Example.from_json_data(Example, data)
    out
  end

  def to_json_data
    Example.to_json_data(value)
  end
end

class Example
  attr_accessor :value

  def self.from_json_data(data)
    out = Example.new
    out.value = Example.from_json_data(String, data)
    out
  end

  def to_json_data
    Example.to_json_data(value)
  end
end

# .rbs file
class Ref
  attr_accessor value: Example

  def self.from_json_data: (untyped) -> Ref
  def to_json_data: () -> untyped
end

class Example
  attr_accessor value: String

  def self.from_json_data: (untyped) -> Example
  def to_json_data: () -> untyped
end

Note: jtd-codegen had to generate a custom type alias here, which is why the code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or “values” schemas at the top level of a schema, jtd-codegen has to emit type aliases in Ruby. In real-world schemas, this doesn’t happen very often.

Code generated from “Type” schemas

“Type” schemas will be converted into the following RBS types:

JSON Typedef type Ruby type
boolean bool
string String
timestamp DateTime
float32 Float
float64 Float
int8 Integer
uint8 Integer
int16 Integer
uint16 Integer
int32 Integer
uint32 Integer

For example,

{
  "properties": {
    "boolean": { "type": "boolean" },
    "string": { "type": "string" },
    "timestamp": { "type": "timestamp" },
    "float32": { "type": "float32" },
    "float64": { "type": "float64" },
    "int8": { "type": "int8" },
    "uint8": { "type": "uint8" },
    "int16": { "type": "int16" },
    "uint16": { "type": "uint16" },
    "int32": { "type": "int32" },
    "uint32": { "type": "uint32" }
  }
}

Generates into:

# .rb file
class Type
  attr_accessor :boolean
  attr_accessor :float32
  attr_accessor :float64
  attr_accessor :int16
  attr_accessor :int32
  attr_accessor :int8
  attr_accessor :string
  attr_accessor :timestamp
  attr_accessor :uint16
  attr_accessor :uint32
  attr_accessor :uint8

  def self.from_json_data(data)
    out = Type.new
    out.boolean = Example::from_json_data(TrueClass, data["boolean"])
    out.float32 = Example::from_json_data(Float, data["float32"])
    out.float64 = Example::from_json_data(Float, data["float64"])
    out.int16 = Example::from_json_data(Integer, data["int16"])
    out.int32 = Example::from_json_data(Integer, data["int32"])
    out.int8 = Example::from_json_data(Integer, data["int8"])
    out.string = Example::from_json_data(String, data["string"])
    out.timestamp = Example::from_json_data(DateTime, data["timestamp"])
    out.uint16 = Example::from_json_data(Integer, data["uint16"])
    out.uint32 = Example::from_json_data(Integer, data["uint32"])
    out.uint8 = Example::from_json_data(Integer, data["uint8"])
    out
  end

  def to_json_data
    data = {}
    data["boolean"] = Example::to_json_data(boolean)
    data["float32"] = Example::to_json_data(float32)
    data["float64"] = Example::to_json_data(float64)
    data["int16"] = Example::to_json_data(int16)
    data["int32"] = Example::to_json_data(int32)
    data["int8"] = Example::to_json_data(int8)
    data["string"] = Example::to_json_data(string)
    data["timestamp"] = Example::to_json_data(timestamp)
    data["uint16"] = Example::to_json_data(uint16)
    data["uint32"] = Example::to_json_data(uint32)
    data["uint8"] = Example::to_json_data(uint8)
    data
  end
end

# .rbs file
class Type
  attr_accessor boolean: bool
  attr_accessor float32: Float
  attr_accessor float64: Float
  attr_accessor int16: Integer
  attr_accessor int32: Integer
  attr_accessor int8: Integer
  attr_accessor string: String
  attr_accessor timestamp: DateTime
  attr_accessor uint16: Integer
  attr_accessor uint32: Integer
  attr_accessor uint8: Integer

  def self.from_json_data: (untyped) -> Type
  def to_json_data: () -> untyped
end

Code generated from “Enum” schemas

“Enum” schemas will be converted into a Ruby class where each element of the enum will be an instance of the class, and with a private constructor (so you can’t accidentally create new instances of the class):

{
  "enum": ["PENDING", "IN_PROGRESS", "DONE"]
}

Generates into:

# .rb file
class Enum
  attr_accessor :value

  def initialize(value)
    self.value = value
  end

  private_class_method :new

  DONE = new("DONE")
  IN_PROGRESS = new("IN_PROGRESS")
  PENDING = new("PENDING")

  def self.from_json_data(data)
    {
      "DONE" => DONE,
      "IN_PROGRESS" => IN_PROGRESS,
      "PENDING" => PENDING,
    }[data]
  end

  def to_json_data
    value
  end
end

# .rbs file
class Enum
  attr_accessor value: String

  DONE: Enum
  IN_PROGRESS: Enum
  PENDING: Enum

  def self.from_json_data: (untyped) -> Enum
  def to_json_data: () -> untyped
end

Code generated from “Elements” schemas

“Elements” schemas will be converted into a Ruby Array of T (in RBS syntax: Array[T]), where T is the type of the elements of the array:

{
  "elements": {
    "type": "string"
  }
}

Generates into:

# .rb file
class Elements
  attr_accessor :value

  def self.from_json_data(data)
    out = Elements.new
    out.value = Example.from_json_data(Array[String], data)
    out
  end

  def to_json_data
    Example.to_json_data(value)
  end
end

# .rbs file
class Elements
  attr_accessor value: Array[String]

  def self.from_json_data: (untyped) -> Elements
  def to_json_data: () -> untyped
end

Note: jtd-codegen had to generate a custom type alias here, which is why the code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or “values” schemas at the top level of a schema, jtd-codegen has to emit type aliases in Ruby. In real-world schemas, this doesn’t happen very often.

Code generated from “Properties” schemas

“Properties” schemas will be converted into a plain old Ruby class. Optional properties will be marked with ? in the RBS, and will be omitted from JSON if set to nil.

{
  "properties": {
    "name": { "type": "string" },
    "isAdmin": { "type": "boolean" }
  },
  "optionalProperties": {
    "middleName": { "type": "string" }
  },
  "additionalProperties": true
}

Generates into:

# .rb file
class Properties
  attr_accessor :is_admin
  attr_accessor :name
  attr_accessor :middle_name

  def self.from_json_data(data)
    out = Properties.new
    out.is_admin = Example::from_json_data(TrueClass, data["isAdmin"])
    out.name = Example::from_json_data(String, data["name"])
    out.middle_name = Example::from_json_data(String, data["middleName"])
    out
  end

  def to_json_data
    data = {}
    data["isAdmin"] = Example::to_json_data(is_admin)
    data["name"] = Example::to_json_data(name)
    data["middleName"] = Example::to_json_data(middle_name) unless middle_name.nil?
    data
  end
end

# .rbs file
class Properties
  attr_accessor is_admin: bool
  attr_accessor name: String
  attr_accessor middle_name: String?

  def self.from_json_data: (untyped) -> Properties
  def to_json_data: () -> untyped
end

Code generated from “Values” schemas

“Values” schemas will be converted into a Ruby Hash from Strings to Ts (in RBS syntax: Hash[String, T]), where T is the type of the values of the object:

{
  "values": {
    "type": "string"
  }
}

Generates into:

# .rb file
class Values
  attr_accessor :value

  def self.from_json_data(data)
    out = Values.new
    out.value = Example.from_json_data(Hash[String, String], data)
    out
  end

  def to_json_data
    Example.to_json_data(value)
  end
end

# .rbs file
class Values
  attr_accessor value: Hash[String, String]

  def self.from_json_data: (untyped) -> Values
  def to_json_data: () -> untyped
end

Note: jtd-codegen had to generate a custom type alias here, which is why the code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or “values” schemas at the top level of a schema, jtd-codegen has to emit type aliases in Ruby. In real-world schemas, this doesn’t happen very often.

Code generated from “Discriminator” schemas

“Discriminator” schemas will be converted into a plain old Ruby class, and each mapping will be a separate Ruby class that extends the discriminator:

{
  "discriminator": "eventType",
  "mapping": {
    "USER_CREATED": {
      "properties": {
        "id": { "type": "string" }
      }
    },
    "USER_PAYMENT_PLAN_CHANGED": {
      "properties": {
        "id": { "type": "string" },
        "plan": { "enum": ["FREE", "PAID"] }
      }
    },
    "USER_DELETED": {
      "properties": {
        "id": { "type": "string" },
        "softDelete": { "type": "boolean" }
      }
    }
  }
}

Generates into:

# .rb file
class Discriminator
  attr_accessor :event_type

  def self.from_json_data(data)
    {
      "USER_CREATED" => DiscriminatorUserCreated,
      "USER_DELETED" => DiscriminatorUserDeleted,
      "USER_PAYMENT_PLAN_CHANGED" => DiscriminatorUserPaymentPlanChanged,
    }[data["eventType"]].from_json_data(data)
  end
end

class DiscriminatorUserCreated < Discriminator
  attr_accessor :id

  def self.from_json_data(data)
    out = DiscriminatorUserCreated.new
    out.event_type = "USER_CREATED"
    out.id = Example::from_json_data(String, data["id"])
    out
  end

  def to_json_data
    data = { "eventType" => "USER_CREATED" }
    data["id"] = Example::to_json_data(id)
    data
  end
end

class DiscriminatorUserDeleted < Discriminator
  attr_accessor :id
  attr_accessor :soft_delete

  def self.from_json_data(data)
    out = DiscriminatorUserDeleted.new
    out.event_type = "USER_DELETED"
    out.id = Example::from_json_data(String, data["id"])
    out.soft_delete = Example::from_json_data(TrueClass, data["softDelete"])
    out
  end

  def to_json_data
    data = { "eventType" => "USER_DELETED" }
    data["id"] = Example::to_json_data(id)
    data["softDelete"] = Example::to_json_data(soft_delete)
    data
  end
end

class DiscriminatorUserPaymentPlanChangedPlan
  attr_accessor :value

  def initialize(value)
    self.value = value
  end

  private_class_method :new

  FREE = new("FREE")
  PAID = new("PAID")

  def self.from_json_data(data)
    {
      "FREE" => FREE,
      "PAID" => PAID,
    }[data]
  end

  def to_json_data
    value
  end
end

class DiscriminatorUserPaymentPlanChanged < Discriminator
  attr_accessor :id
  attr_accessor :plan

  def self.from_json_data(data)
    out = DiscriminatorUserPaymentPlanChanged.new
    out.event_type = "USER_PAYMENT_PLAN_CHANGED"
    out.id = Example::from_json_data(String, data["id"])
    out.plan = Example::from_json_data(DiscriminatorUserPaymentPlanChangedPlan, data["plan"])
    out
  end

  def to_json_data
    data = { "eventType" => "USER_PAYMENT_PLAN_CHANGED" }
    data["id"] = Example::to_json_data(id)
    data["plan"] = Example::to_json_data(plan)
    data
  end
end
# .rbs file
class Discriminator
  attr_accessor event_type: String

  def self.from_json_data: (untyped) -> Discriminator
  def to_json_data: () -> untyped
end

class DiscriminatorUserCreated < Discriminator
  attr_accessor id: String

  def self.from_json_data: (untyped) -> DiscriminatorUserCreated
  def to_json_data: () -> untyped
end

class DiscriminatorUserDeleted < Discriminator
  attr_accessor id: String
  attr_accessor soft_delete: bool

  def self.from_json_data: (untyped) -> DiscriminatorUserDeleted
  def to_json_data: () -> untyped
end

class DiscriminatorUserPaymentPlanChangedPlan
  attr_accessor value: String

  FREE: DiscriminatorUserPaymentPlanChangedPlan
  PAID: DiscriminatorUserPaymentPlanChangedPlan

  def self.from_json_data: (untyped) -> DiscriminatorUserPaymentPlanChangedPlan
  def to_json_data: () -> untyped
end

class DiscriminatorUserPaymentPlanChanged < Discriminator
  attr_accessor id: String
  attr_accessor plan: DiscriminatorUserPaymentPlanChangedPlan

  def self.from_json_data: (untyped) -> DiscriminatorUserPaymentPlanChanged
  def to_json_data: () -> untyped
end