Julia, custom serialization with JSON.jl

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 đŸ™‚