Crystal AOT compilation and JSON::Serializable macro: generic union type resolution across compilation units
0 reputation · 26 Aug 2022, 17:45 UTC
0 reputation · 26 Aug 2022, 17:45 UTC
Crystal's ahead-of-time compiler performs whole-program type inference while the JSON::Serializable macro expands at compile time to generate serialization code. When a generic type contains a union of concrete types that are only fully resolved after macro expansion in a separate compilation unit, the interaction between these two compile-time phases becomes ambiguous.
The compiler must infer the complete type graph before emitting native code, yet macro expansion can introduce new type dependencies that were not visible during the initial inference pass. This is particularly relevant for library authors who expose generic APIs with JSON::Serializable included, where downstream applications may instantiate those generics with unions defined in application code. The current documentation does not specify whether the compiler re-runs type inference after macro expansion across unit boundaries, or whether a fixed-point iteration is guaranteed.
JSON::Serializable macro expansion completes across all compilation units?JSON::Serializable when used across shared-library boundaries?35275 reputation · 27 Aug 2022, 04:18 UTC
The question assumes a C/C++-style model with separate compilation units and a boundary between "library code" and "application code." Crystal does not work that way. The compiler always performs whole-program compilation: every reachable source file (via require) is parsed into a single program, and there is one semantic analysis pass over all of it. A generic in a shard and a union in your application are typed together, in the same pass.
The pipeline is roughly:
JSON::Serializable is a macro module: it expands from_json/to_json methods into the AST. This happens before any typing.Wrapper(T) is not compiled until something writes Wrapper(A | B).When you write Wrapper(A | B), the compiler instantiates the generic once with T = (A | B) and types the body under that binding. Because compilation is whole-program, A and B can live in application code while Wrapper lives in a shard — it makes no difference. For JSON::Serializable, the generated deserialization for a union-typed field uses the stdlib's union path (try each member in order, first match wins), and that code is itself macro-expanded before typing, so application-defined union members are handled exactly like stdlib types.
Crystal has no stable ABI and no way to ship a compiled generic Crystal API for separate instantiation — "across shared-library boundaries" does not apply. Recursive and mutually recursive types are supported (the stdlib's own JSON::Any is a recursive alias), and JSON::Serializable works with them. The practical limits are compile time and occasional recursive-type inference errors that require an explicit annotation (e.g., on an ivar or getter) to break the cycle.
# wrapper.cr
class Wrapper(T)
include JSON::Serializable
getter value : T
end
class A
include JSON::Serializable
getter x : Int32
end
class B
include JSON::Serializable
getter y : String
end
w = Wrapper(A | B).from_json(%({"value":{"y":"hi"}}))
pp w
crystal build wrapper.cr && ./wrapper
crystal tool expand -c wrapper.cr:3 wrapper.cr # inspect generated codecrystal tool expand shows the macro output before typing, confirming expansion precedes inference. One caveat to test in your own code: for overlapping union members (e.g., Int32 | Float64 or two similar serializable types), deserialization tries members in order and the first match wins, which can surprise.
Version note: this describes Crystal's long-standing whole-program design (true across 1.x); exact pipeline internals are compiler-version sensitive. If you hit a recursive-inference error, the missing diagnostic is your Crystal version and the exact error message.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.