By: Picaud Vincent
Re-posted from: https://pixorblog.wordpress.com/2026/05/05/julia-custom-serialization-with-json-jl/
Introduction
The GitHub:JSON3.jl package has been deprecated. That bothered me a little because I had to migrate a lot of my code to use GitHub:JSON.jl. Luckily, the migration turned out to be easier than I expected.
My use case is a bit special: I have to serialize my structures with type information so that I can retrieve the exact types after deserialization.
I know about GitHub:BSON.jl (see also Wiki:BSON) and Julia:Serialization, but I didn’t want to use them because they produce binary files. I wanted to keep a human‑readable format.
In this note I give a minimal working example that might save you some time.
Code
We’ll need the JSON.jl package. We also use StaticArrays.jl to show how to preserve the right vector type when deserializing an AbstractVector.
using JSON using StaticArrays
Let’s imagine we have an abstract type Abstract_Foo and two concrete types: Foo_A and Foo_B.
abstract type Abstract_Foo end
@nonstruct struct Foo_A{V <: AbstractVector} <: Abstract_Foo
v::V
x::Float64
end
@nonstruct struct Foo_B <: Abstract_Foo
v::AbstractVector
n::Int
end
Nothing special here, except the @nonstruct macro. That macro comes from GitHub:StructUtils.jl, a package used by JSON.jl to automate common struct operations (construction, etc.).
Using Doc:@nonstruct in front of a struct definition marks it as “special”. You tell JSON.jl to treat it as a primitive type that should be converted directly using lift() and lower() methods, rather than constructing it from field values. In short, you have to do all the work by hand, but you also get all the freedom to serialize and deserialize the structure however you want.
Serialization
During serialization the lower() method is called. We save the field values but also any type information needed for deserialization. Personally, I store this information in a field called type that holds the type of the structure. The name type isn’t special, you could call it internal_type, but I think it’s good practice to adopt a convention and stick to it.
function StructUtils.lower(to_serialize::Foo_A)
return (type = string(typeof(to_serialize)),
v = to_serialize.v,
x = to_serialize.x)
end
For Foo_B, it’s a bit more complicated because the v field is an AbstractVector type, so we need an extra field to save the type information:
function StructUtils.lower(to_serialize::Foo_B)
return (type = string(typeof(to_serialize)),
v_type = string(typeof(to_serialize.v)),
v = to_serialize.v,
n = to_serialize.n)
end
Demonstration
Here’s a demonstration of serialization:
a = Foo_A(@SVector(Int[1,2]),1.2) a_json_str = JSON.json(a, pretty=true)
{
"type": "Foo_A{SVector{2, Int64}}",
"v": [
1,
2
],
"x": 1.2
}
Now for Foo_B
b = Foo_B(Float16[3,4],34) b_json_str = JSON.json(b, pretty=true)
{
"type": "Foo_B",
"v_type": "Vector{Float16}",
"v": [
3.0,
4.0
],
"n": 34
}
Deserialization
To deserialize you have to define the lift() methods.
First, we intercept all Abstract_Foo occurrences and extract the concrete type. Right now the type is a String, to turn it into a Julia DataType we use Base.eval() and Meta.parse(). Once we have that instantiated type, we continue deserialization with it.
function StructUtils.lift(type::Type{<:Abstract_Foo},
to_deserialize)
actual_type = Base.eval(Main,Meta.parse(to_deserialize.type))
StructUtils.lift(actual_type,to_deserialize)
end
Now we redefine lift() for the specific concrete types. You have to be careful to define these new methods for all possible specializations, otherwise you’ll get an infinite recursion with the previous function. It would be nice to detect this situation, but how? (feel free to add a comment
)
For Foo_A:
function StructUtils.lift(type::Type{<:Foo_A{V}},
to_deserialize) where {V<:AbstractVector}
v = StructUtils.lift(V,to_deserialize.v) # deserialize vect.
x = to_deserialize.x
type(v,x)
end
For Foo_B:
function StructUtils.lift(type::Type{<:Foo_B},
to_deserialize)
v_type = Base.eval(Main,Meta.parse(to_deserialize.v_type))
v = StructUtils.lift(v_type,to_deserialize.v) # deserialize vect.
n = to_deserialize.n
type(v,n)
end
Demonstration
Notice that we don’t need to give the exact type, just Abstract_Foo is enough.
JSON.parse(a_json_str,Abstract_Foo)
Foo_A{SVector{2, Int64}}([1, 2], 1.2)
JSON.parse(b_json_str,Abstract_Foo)
Foo_B(Float16[3.0, 4.0], 34)
Remarks
@kwdef and @nonstruct together
You cannot use @kwdef and @nonstruct together. The following code generates an error:
@nonstruct @kwdef struct Foo_C <: Abstract_Foo end
The solution is to do the work of @nonstruct by hand. First, look at what this macro does:
@macroexpand @nonstruct struct Foo_C <: Abstract_Foo end
quote
begin
$(Expr(:meta, :doc))
struct Foo_C <: Abstract_Foo
end
end
StructUtils.structlike(::StructUtils.StructStyle, ::Type{<:Foo_C}) = false
end
So the fix is simply to replace
@nonstruct @kwdef struct Foo_C <: Abstract_Foo end
by
@kwdef struct Foo_C <: Abstract_Foo
end
StructUtils.structlike(::StructUtils.StructStyle,
::Type{<:Foo_C}) = false
Writing / reading file
Please follow the JSON.jl official doc, nothing special here:
JSON.json(file, a, pretty=true) # write file JSON.parsefile(file, Abstract_Foo) # read file
Complete code
To make your life easier, here’s the complete code:
using JSON
using StaticArrays
abstract type Abstract_Foo end
@nonstruct struct Foo_A{V <: AbstractVector} <: Abstract_Foo
v::V
x::Float64
end
@nonstruct struct Foo_B <: Abstract_Foo
v::AbstractVector
n::Int
end
function StructUtils.lower(to_serialize::Foo_A)
return (type = string(typeof(to_serialize)),
v = to_serialize.v,
x = to_serialize.x)
end
function StructUtils.lower(to_serialize::Foo_B)
return (type = string(typeof(to_serialize)),
v_type = string(typeof(to_serialize.v)),
v = to_serialize.v,
n = to_serialize.n)
end
a = Foo_A(@SVector(Int[1,2]),1.2)
a_json_str = JSON.json(a, pretty=true)
println(a_json_str)
b = Foo_B(Float16[3,4],34)
b_json_str = JSON.json(b, pretty=true)
println(b_json_str)
function StructUtils.lift(type::Type{<:Abstract_Foo},
to_deserialize)
actual_type = Base.eval(Main,Meta.parse(to_deserialize.type))
StructUtils.lift(actual_type,to_deserialize)
end
function StructUtils.lift(type::Type{<:Foo_A{V}},
to_deserialize) where {V<:AbstractVector}
v = StructUtils.lift(V,to_deserialize.v) # deserialize vect.
x = to_deserialize.x
type(v,x)
end
function StructUtils.lift(type::Type{<:Foo_B},
to_deserialize)
v_type = Base.eval(Main,Meta.parse(to_deserialize.v_type))
v = StructUtils.lift(v_type,to_deserialize.v) # deserialize vect.
n = to_deserialize.n
type(v,n)
end
JSON.parse(a_json_str,Abstract_Foo)
JSON.parse(b_json_str,Abstract_Foo)
Conclusion
There’s nothing more ridiculous than a conclusion, because nothing is ever finished. But I admit it’s still handy to say goodbye