ActiveSupport #as_json vs #to_json
Something that comes up when writing API endpoints in Rails is whether to use #as_json
or #to_json
. Despite the methods sounding synonymous, they serve two distinct purposes. Yet a surprising amount of implicit calling happens making them feel more similar than they actually are.
#as_json is a lie
A natural assumption for “as json” is that it returns the object as a JSON string. False. Check out the difference between the outputs of #as_json
and #to_json
:
book = { id: 42, name: "The Hitchhiker's Guide to the Galaxy", published: Date.new(1979, 10, 12) }
#=> {:id=>42, :name=>"The Hitchhiker's Guide to the Galaxy", :published=>Fri, 12 Oct 1979}
book.as_json
#=> {"id"=>42, "name"=>"The Hitchhiker's Guide to the Galaxy", "published"=>"1979-10-12"}
book.to_json
#=> "{\"id\":42,\"name\":\"The Hitchhiker's Guide to the Galaxy\",\"published\":\"1979-10-12\"}"
While the result of #as_json
may look like JSON, it’s actually a Ruby hash. It is #to_json
that returns a serialized JSON string. So clearly not an alias: one returns a hash, the other a string.
book.as_json.class
#=> Hash
book.to_json.class
#=> String
So if we start with a hash, call #as_json
on it, then get back a hash… what have we gained? First, the hash keys are converted to strings rather than symbols. Second, the :published
value becomes a string representation of the date rather than a Date
object. Since JSON only supports string, number, boolean, and null values, anything more complex must be simplified.
Caveat: JSON also supports array and object values, but those are just structural containers that allow for lists and hierarchy. The atomic value types are string, number, boolean, and null.
Simplify then serialize
Ruby separates the concerns of simplification and serialization. #as_json
is responsible for simplification: it normalizes Ruby objects to JSON compatible primitives. #to_json
is responsible for serialization: it generates a raw JSON string with proper escaping and keywords (e.g. nil
to null
). So it makes sense to use both methods in tandem. Which is exactly what happens in many of the #to_json
definitions:
def to_json(*args)
as_json.to_json(*args)
end
The delegation to #as_json
lets #to_json
focus on serialization, not simplifying objects to primitives.
NOTE: Both #as_json
and #to_json
are defined in the Ruby Standard library. However, you can pretty much ignore Ruby’s implementation of #as_json
since it’s entirely overridden by ActiveSupport.
Adapt objects to Hash
ActiveSupport extends Object#as_json
to work with all objects. The method first converts an object to a hash using #to_hash
, then calls #as_json
on the resulting hash. So #to_hash
acts as an adapter to normalize objects to a common type: Hash
. Then the single Hash#as_json
definition can work on any of the adapted objects.
# lib/active_support/core_ext/object/json.rb
class Object
def as_json(options = nil)
if respond_to?(:to_hash)
to_hash.as_json(options)
else
instance_values.as_json(options)
end
end
# so basically this…
def as_json(*)
to_hash.as_json(*)
end
end
So Hash#as_json
is where the real work happens. A majority of the method deals with filtering down the hash using only/except, but the last line is the most interesting. It loops over each key/value pair in the hash and makes them more JSON-like, calling #to_s
on the key and #as_json
on the value.
# lib/active_support/core_ext/object/json.rb
class Hash
def as_json(options = nil)
# create a subset of the hash by applying :only or :except
subset = if options
# …
else
self
end
Hash[subset.map { |k, v| [k.to_s, options ? v.as_json(options.dup) : v.as_json] }]
end
# so basically this…
def as_json(*)
Hash[map { |k, v| k.to_s, v.as_json }]
end
end
Array#as_json
works similar, calling #as_json
on each value in the array:
# lib/active_support/core_ext/object/json.rb
class Array
def as_json(options = nil)
map { |v| options ? v.as_json(options.dup) : v.as_json }
end
# so basically this…
def as_json(*)
map(&:as_json)
end
end
So calling #as_json
on a Hash or Array will recursively call #as_json
on each of their values. But what happens when you call #as_json
on a primitive object such as a string or number?
#as_json all the things!
ActiveSupport provides #as_json
definitions for many core and standard Ruby classes. An example of this was seen earlier when #as_json
simplified the Date
object to a nicely formatted ISO 8601 string:
# lib/active_support/core_ext/object/json.rb
class Date
def as_json(options = nil)
if ActiveSupport::JSON::Encoding.use_standard_json_time_format
strftime("%Y-%m-%d")
else
strftime("%Y/%m/%d")
end
end
# so basically this…
def as_json(*)
strftime("%Y-%m-%d")
end
end
# Example:
Date.new(1979, 10, 12).as_json
#=> "1979-10-12"
Other Ruby objects are already JSON compatible so #as_json
returns the values unmodified:
# lib/active_support/core_ext/object/json.rb
class String
def as_json(options = nil)
self
end
end
class Numeric
def as_json(options = nil)
self
end
end
# Examples:
"book".as_json
#=> "book"
42.as_json
#=> 42
For others still, ActiveSupport uses #to_s
to simplify the objects to meaningful strings:
# lib/active_support/core_ext/object/json.rb
class Symbol
def as_json(options = nil)
to_s
end
end
class BigDecimal
def as_json(options = nil)
finite? ? to_s : nil
end
end
class Range
def as_json(options = nil)
to_s
end
end
# Examples:
:book.as_json
#=> "book"
BigDecimal(42).as_json
#=> "42.0"
(1..3).as_json
#=> "1..3"
The point is, #as_json
simplifies all objects to either a single JSON atomic value type or to a hash or array with multiple values for more complex objects.
Which should I use?
#to_json
is the actual serialization method that outputs a JSON string. It should be the very last modification to your JSON. And most of the time you don’t need to actually call it… your framework will do it for you. There might be times when you need to explicitly serialize to JSON, like when storing a value in Redis or PostgreSQL, but that’s not the typical case in Rails apps.
#as_json
is a bit more useful. It outputs a Ruby hash with string keys and primitive values. This simplifies modifications, such as merging in an extra attribute or building out a larger response made of multiple objects. That said, you only need to call #as_json
if you need to modify the structure since #to_json
will implicitly call it for you.
So which method should you use? Neither. Instead, build a Hash
in the shape you want the JSON response and pass it directly to render. Let your framework handle the rest. That means render json: hash
in Rails or json(hash)
in Sinatra. If you must have a JSON string, you can use hash.to_json
assuming ActiveRecord, or JSON.generate(hash)
for a non-Rails environment.