Porting shiny_json_logic to Crystal: What the type system taught me about JSON Logic
Introducing JsonLogic to Crystal
I’ve been maintaining shiny_json_logic (a Ruby JSON Logic implementation with 100% official test compliance) for a while now. When I decided to port it to Crystal, I expected a mechanical translation job but it wasn’t!
This is the story of what changed, what broke, and what Crystal forced me to do better.
#Why Crystal
The Ruby gem was already passing 601/601 official tests and winning benchmarks against every other Ruby implementation. But Ruby is an interpreted, dynamically typed language. JSON Logic is a tree-walking interpreter — every apply call recurses through a rule, pattern-matches on operator names, and dispatches to the right handler.
That’s exactly the kind of workload where a compiled, statically typed language shines. Crystal has Ruby-like syntax, compiles to native binaries, and has a type system that catches mistakes at compile time. It felt like the right next step.
#The architecture
The Ruby implementation is organized around a registry of operation classes:
module ShinyJsonLogic
module Operations
class Var < Base
def self.execute(rules, scope_stack)
# ...
end
end
end
end
Each operator is a class that inherits from Base. The engine looks up the operator name, finds the class, and calls execute. This works in Ruby because everything is dynamic as you can store class references and call methods on them at runtime without the type system complaining.
Crystal needs more precision. The equivalent in Crystal uses modules with self.call and a type-annotated signature:
module ShinyJsonLogic
module Operations
module Var
def self.call(args : JSON::Any, scope_stack : Array(JSON::Any)) : JSON::Any
# ...
end
end
end
end
ShinyJsonLogic::Engine.register("var") { |args, scope_stack| ShinyJsonLogic::Operations::Var.call(args, scope_stack) }
The registry stores Proc(JSON::Any, Array(JSON::Any), JSON::Any) — closures that wrap each module’s call method. Crystal’s type inference handles the rest.
#The hard part: nil vs JSON null
In Ruby, nil does double duty. It means “this key doesn’t exist in the hash” and “this key exists but its value is JSON null”. That ambiguity is manageable in a dynamic language, you can always resort to hash.key? to distinguish the two cases.
In Crystal, JSON::Any has a raw property typed as a union:
alias Type = Nil | Bool | Int64 | Float64 | String | Array(JSON::Any) | Hash(String, JSON::Any)
Nil here means JSON null, not “key absent”. So when walking a data hash looking up a path like "user.address.city", you need a different signal for “key not found” vs “key found with null value”.
The solution was to make fetch_value_or_missing return JSON::Any? — Crystal nil (Nil) for “key not found”, JSON::Any.new(nil) for “key found with JSON null”:
def self.fetch_value_or_missing(obj : JSON::Any, key : String) : JSON::Any?
return nil if obj.raw.nil? # Crystal nil: absent
ScopeStack.fetch_key(obj, key) # Returns JSON::Any? — nil if absent, JSON::Any if found
end
This distinction matters for the var operator’s default value logic:
result = fetch_value_or_missing(current, key)
result.nil? ? default : result # nil = absent → use default; JSON::Any = found → use it
In Ruby this same bug caused silent failures in feature flags — {"var": "is_beta_tester"} with {"is_beta_tester": false} would return nil in some implementations because they used hash[key] or hash.dig without distinguishing nil from absence. Crystal’s type system makes the distinction unavoidable.
#Truthiness
JSON Logic has specific truthiness rules that differ from both Ruby and Crystal’s native semantics:
false→ falsy0→ falsy""→ falsynull→ falsy[]→ falsy (empty array){}→ falsy (empty object) ← this is the one most implementations get wrong
In Ruby, only nil and false are falsy. In Crystal, same. So both implementations need an explicit Truthy module. Crystal’s case/when with union type matching makes this clean:
module ShinyJsonLogic
module Truthy
def self.call(subject : JSON::Any) : Bool
raw = subject.raw
case raw
when Bool then raw
when Int64 then raw != 0_i64
when Float64 then raw != 0.0
when String then !raw.empty?
when Nil then false
when Hash then !raw.empty?
when Array then raw.any? { |item| call(item) }
else true
end
end
end
end
The when Hash then !raw.empty? line is the one that catches most other implementations out. An empty object {} is falsy per the JSON Logic spec. Crystal’s exhaustive pattern matching means if I add a new when branch that doesn’t return Bool, the compiler tells me immediately.
#The numbers
Crystal 1.14.0, Linux, 601/601 official tests:
692,542 ops/second
Ruby 3.4 (same machine, same tests):
~39,000 ops/second
That’s roughly 17x faster. Not surprising — Crystal compiles to native code and the type information eliminates most of the runtime dispatch overhead that Ruby has. But it’s a satisfying number.
The compliance is what matters more to me though. 601/601. Same as Ruby. Same as PHP. All three implementations agree on every edge case.
#What Crystal taught me
Three things stood out:
1. The type system finds spec ambiguities. Anywhere the JSON Logic spec is vague about “absent” vs “null”, Crystal forces you to make a decision. That decision is then visible in the type signature. In Ruby you can paper over it with nil and move on. In Crystal you can’t.
2. Modules over classes for stateless operations. Ruby’s inheritance hierarchy (class Var < Base) adds indirection without adding value when the methods are all static. Crystal’s modules with self.call are more honest about what’s happening.
3. JSON::Any is a better model than Ruby’s implicit duck typing. In Ruby, data coming in from JSON can be a Hash, Array, String, Integer, Float, TrueClass, FalseClass, or NilClass. In Crystal it’s all JSON::Any, and you unwrap it explicitly. More verbose, but the explicitness prevents a whole class of bugs.
#The shard
dependencies:
shiny_json_logic:
github: luismoyano/shiny-json-logic-crystal
601/601 official tests. Zero dependencies. Crystal 1.0+.
Part of the Shiny JSON Logic family — Ruby, PHP, and Crystal, all passing the full official test suite.
- GitHub: luismoyano/shiny-json-logic-crystal
- Benchmarks: shinyjsonlogic.com/benchmarks