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/falsemeans 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:
- It defines a getter that wraps the JSON hash in a
DataAccessor - The
DataAccessorusesmethod_missingto provide typed access to each key - Type coercion uses
ActiveModel::Type.lookup, the same system Rails uses for database columns - Defaults are applied via
reverse_merge!when the accessor is instantiated - A
before_savecallback 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:
-
Only three types. Boolean, integer, string. No dates, arrays, or nested objects.
-
No nesting. You can’t define a schema within a schema. Each key must be a primitive.
-
No validation. There’s no built in way to validate values (like
max_invitesmust be positive). Add your own validations. -
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.