Rails 8.2 Preview: Type Safe JSON Attributes with has_json

Note: This feature is coming in Rails 8.2. It’s merged to main but not yet released. You can view the source on GitHub or try it by pointing your Gemfile at the main branch.


Every Rails developer has written this code:

class Account < ApplicationRecord
  def settings
    super || {}
  end

  def max_invites
    settings["max_invites"]&.to_i
  end

  def max_invites=(value)
    settings["max_invites"] = value.to_i
    save_settings!
  end

  def restrict_admins_only?
    ActiveModel::Type::Boolean.new.cast(settings["restrict_admins_only"])
  end

  def restrict_admins_only=(value)
    settings["restrict_admins_only"] = ActiveModel::Type::Boolean.new.cast(value)
    save_settings!
  end

  private

  def save_settings!
    self.settings = settings # trigger the dirty tracking
  end
end

And it gets worse with every setting you add.

Rails 8.2 introduces has_json, which reduces this to:

class Account < ApplicationRecord
  has_json :settings, restrict_admins_only: false, max_invites: 10
end

That’s it. Type coercion, defaults, and accessors. All handled.

The Problem: Form Params Are Always Strings

When a user submits a form, everything arrives as strings:

params[:account][:settings]
# => { "max_invites" => "50", "restrict_admins_only" => "true" }

If you store this directly in a JSON column, you get string values in your database:

{"max_invites": "50", "restrict_admins_only": "true"}

Now your code is littered with .to_i and boolean casting. Comparisons break silently:

account.settings["max_invites"] > 25  # => false (string comparison!)
account.settings["restrict_admins_only"] == true  # => false (it's "true", not true)

How has_json Solves This

has_json creates a schema aware accessor that automatically coerces types:

class Account < ApplicationRecord
  has_json :settings,
    restrict_admins_only: true,   # boolean, defaults to true
    max_invites: 10,              # integer, defaults to 10
    welcome_message: "Hello!"     # string, defaults to "Hello!"
end

The schema is defined by the default values. Rails infers the type from each value:

  • true / false means boolean
  • Integer means integer
  • String means string

Now form params just work:

account = Account.new
account.settings = {
  "max_invites" => "50",
  "restrict_admins_only" => "false"
}

account.settings.max_invites           # => 50 (integer!)
account.settings.restrict_admins_only? # => false (boolean!)

Real Example: User Preferences Panel

Let’s build a preferences panel where users configure their notification settings.

The Migration

class AddPreferencesToUsers < ActiveRecord::Migration[8.2]
  def change
    add_column :users, :preferences, :jsonb, default: {}, null: false
  end
end

The Model

class User < ApplicationRecord
  has_json :preferences,
    email_notifications: true,
    sms_notifications: false,
    digest_frequency: 7,        # days between digest emails
    timezone: "UTC",
    items_per_page: 25
end

The Form

<%# app/views/users/preferences.html.erb %>
<%= form_with model: current_user, url: preferences_path, method: :patch do |f| %>
  <fieldset>
    <legend>Notifications</legend>

    <%= f.label :email_notifications do %>
      <%= f.check_box :email_notifications,
          checked: current_user.preferences.email_notifications? %>
      Email notifications
    <% end %>

    <%= f.label :sms_notifications do %>
      <%= f.check_box :sms_notifications,
          checked: current_user.preferences.sms_notifications? %>
      SMS notifications
    <% end %>

    <%= f.label :digest_frequency %>
    <%= f.select :digest_frequency,
        [["Daily", 1], ["Weekly", 7], ["Monthly", 30]],
        selected: current_user.preferences.digest_frequency %>
  </fieldset>

  <fieldset>
    <legend>Display</legend>

    <%= f.label :items_per_page %>
    <%= f.select :items_per_page,
        [10, 25, 50, 100],
        selected: current_user.preferences.items_per_page %>

    <%= f.label :timezone %>
    <%= f.time_zone_select :timezone,
        nil,
        default: current_user.preferences.timezone %>
  </fieldset>

  <%= f.submit "Save Preferences" %>
<% end %>

The Controller

class PreferencesController < ApplicationController
  def update
    # All the type coercion happens automatically
    current_user.preferences = preferences_params
    current_user.save!

    redirect_to preferences_path, notice: "Preferences saved!"
  end

  private

  def preferences_params
    params.require(:user).permit(
      :email_notifications,
      :sms_notifications,
      :digest_frequency,
      :items_per_page,
      :timezone
    )
  end
end

No manual type casting. The form sends strings, has_json converts them to the correct types based on the schema.

Using has_delegated_json for Top Level Access

If you want to skip the .preferences accessor and access settings directly on the model, use has_delegated_json:

class User < ApplicationRecord
  has_delegated_json :preferences,
    email_notifications: true,
    sms_notifications: false,
    digest_frequency: 7
end

user = User.new
user.email_notifications?  # => true (delegated to preferences.email_notifications?)
user.digest_frequency = 30
user.digest_frequency      # => 30

This is useful when your JSON column acts as an extension of the model rather than a nested object.

Declaring Types Without Defaults

Sometimes you want to specify a type without a default value. Use symbols:

class Account < ApplicationRecord
  has_json :settings,
    max_invites: :integer,     # integer, defaults to nil
    beta_enabled: :boolean,    # boolean, defaults to nil
    custom_domain: :string     # string, defaults to nil
end

account = Account.new
account.settings.max_invites    # => nil
account.settings.beta_enabled?  # => false (nil is falsey)

This is handy for optional settings that shouldn’t have a default.

Real Example: Multi Tenant Feature Flags

Here’s a practical use case: per tenant feature flags stored in JSON.

class Tenant < ApplicationRecord
  has_json :feature_flags,
    new_dashboard: false,
    api_v2: false,
    ai_assistant: false,
    max_users: 5,
    max_storage_gb: 10
end

In your application:

class ApplicationController < ActionController::Base
  before_action :check_feature_access

  private

  def feature_enabled?(flag)
    current_tenant.feature_flags.public_send("#{flag}?")
  end
  helper_method :feature_enabled?

  def check_feature_access
    # Automatically enforce limits
    if current_tenant.users.count >= current_tenant.feature_flags.max_users
      redirect_to upgrade_path if action_name == "create" && controller_name == "users"
    end
  end
end

In your views:

<% if feature_enabled?(:new_dashboard) %>
  <%= render "dashboards/new_layout" %>
<% else %>
  <%= render "dashboards/legacy_layout" %>
<% end %>

<% if feature_enabled?(:ai_assistant) %>
  <%= render "shared/ai_chat_widget" %>
<% end %>

Admin panel to toggle flags:

class Admin::TenantsController < AdminController
  def update_flags
    tenant = Tenant.find(params[:id])
    tenant.feature_flags = feature_flag_params
    tenant.save!

    redirect_to admin_tenant_path(tenant), notice: "Flags updated"
  end

  private

  def feature_flag_params
    params.require(:tenant).permit(
      :new_dashboard, :api_v2, :ai_assistant, :max_users, :max_storage_gb
    )
  end
end

Form checkboxes send “1”/“0”, number inputs send strings. has_json handles all of it.

How It Works Under the Hood

The implementation is elegant. When you call has_json:

  1. It defines a getter that wraps the JSON hash in a DataAccessor
  2. The DataAccessor uses method_missing to provide typed access to each key
  3. Type coercion uses ActiveModel::Type.lookup, the same system Rails uses for database columns
  4. Defaults are applied via reverse_merge! when the accessor is instantiated
  5. A before_save callback ensures defaults are written before persistence

The actual coercion happens here:

def lookup_schema_type_for(key)
  type_or_default_value = @schema[key.to_sym]

  case type_or_default_value
  when :boolean, :integer, :string
    ActiveModel::Type.lookup type_or_default_value
  when TrueClass, FalseClass
    ActiveModel::Type.lookup :boolean
  when Integer
    ActiveModel::Type.lookup :integer
  when String
    ActiveModel::Type.lookup :string
  end
end

This means you get the same coercion behavior as regular ActiveRecord attributes. "true", "1", "yes" all become true. "42" becomes 42.

Limitations to Know

Before you go all in, know the constraints:

  1. Only three types. Boolean, integer, string. No dates, arrays, or nested objects.

  2. No nesting. You can’t define a schema within a schema. Each key must be a primitive.

  3. No validation. There’s no built in way to validate values (like max_invites must be positive). Add your own validations.

  4. method_missing overhead. Each attribute access goes through method_missing. For hot paths, consider caching.

For complex JSON structures, you’ll still want store_accessor or a dedicated model.

Migrating from Manual Accessors

If you have existing JSON columns with manual accessors, migration is straightforward:

Before:

class Account < ApplicationRecord
  def settings
    super || {}
  end

  def max_invites
    settings["max_invites"]&.to_i || 10
  end

  def max_invites=(value)
    self.settings = settings.merge("max_invites" => value.to_i)
  end

  # ... 20 more methods like this
end

After:

class Account < ApplicationRecord
  has_json :settings, max_invites: 10 # ... add all your other fields
end

The JSON structure stays the same. Your existing data works without migration.

Try It Today

While waiting for Rails 8.2, you can try has_json by pointing at main:

# Gemfile
gem "rails", github: "rails/rails", branch: "main"

Or extract just the module. It’s about 100 lines and has no dependencies.

Wrapping Up

If you’re tired of writing .to_i and ActiveModel::Type::Boolean.new.cast for every JSON field, has_json is for you. It handles user preferences, feature flags, and configuration so you can stop writing the same coercion code over and over.

See PR #56258 for the full implementation and discussion.