Parsing structs with computed fields in JSON.jl v1

I have the following problem migrating from JSON3.jl to JSON.jl v1:

julia> struct MyStruct
               a::Int
               b::String
               # MyStruct(a::Int) = new(a, string(a)) this won't work even with JSON3
               MyStruct(a::Int, ::Nothing = nothing) = new(a, string(a))
           end

julia> JSON.parse("""{"a": 42}""", MyStruct)
ERROR: TypeError: in typeassert, expected String, got a value of type Nothing
...

julia> JSON3.read("""{"a": 42}""", MyStruct) # works out of the box
MyStruct2(42, "42")

I haven’t been able to figure out how to make this work. There is StructUtils.@defaults, which works for construction but not for parsing:

julia> @defaults struct MyStruct2
                            a::Int
                            b::String = string(a)
                        end

julia> MyStruct2(1)
MyStruct2(1, "1")

julia> JSON.parse("""{"a": 42}""", MyStruct2)
ERROR: UndefVarError: `a` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
...

EDIT: corrected mistakes pointed out below

I think JSON.jl wants you to represent such a type with explicit option, a la

struct MyStruct
a::Int
b::Union{String, Nothing, Missing} #nothing: value not set in json; missing: explicit null but key is present
end

If you want to conflate {"a":1} and {"a":1, "b":null} then you can use simple Union{Nothing, String}.

That being said, you should open an issue on StructUtils. The generated default constructors and StructUtils.fielddefaults should ideally have the same behavior on this.

(I fear that this will be not be entirely trivial to get right without breakage, especially side-effects and their ordering of default argument argument evaluation needs to be the same between “default arguments for function / type constructor” and “construction via JSON.parse / StructUtils.fielddefaults”).

julia> @macroexpand @defaults struct A
       x::Int
       y::Int = x
       end
quote
    begin
        $(Expr(:meta, :doc))
        struct A
            #= REPL[3]:2 =#
            x::Int
            #= REPL[3]:3 =#
            y::Int
        end
    end
    function A(x)
        #= REPL[3]:1 =#
        return A(x, x)
    end
    StructUtils.fielddefaults(::StructUtils.StructStyle, ::Type{<:A}) = begin
            y = x
            return (; $(Expr(:(=), :y, :y)))
        end
    A
end

Thanks for the suggestions. Unless I misunderstood, even if I change the field type to allow Nothing, I would not get the computed value by parsing. Moreover, I would then need to account for the possibility of Nothing in all downstream code, or alternatively convert it to a different type before further use.

So far, I’ve been working around this issue by doing custom StructUtils.make functions such as

function StructUtils.make(::Type{MyStruct2}, x)
    if haskey(x, :b)
        return MyStruct2(x.a, x.b)
    else
        return MyStruct2(x.a, string(x.a))
    end
end

i.e. getting rid of the custom internal constructor and pattern matching “by hand”. But this gets tedious if you have dozens of structs you want to serialize, and multiple computed fields, and are changing them frequently. I would love to only need to update the struct definition. For more mature interfaces I’m not opposed to write a shim such that the serializable structure can deviate in more ways from the internal one.

I opened an issue on the JSON repo a while ago: Parsing structs with computed fields · Issue #430 · JuliaIO/JSON.jl · GitHub, it might have been user error but it can also be opened on StructUtils.jl.

It appears that you understood perfectly fine, and it was me who misunderstood your original question.

If that works, then it should be possible to improve StructUtils.@defaults to also generate exactly that StructUtils.make for you. Ideally in a PR / upstream, but you can also write your own.

I think this boils down to “StructUtils.fielddefaults as a mechanism / abstraction cannot work, scrap it”, which is what I meant with “this may not be entirely trivial to get right without breakage”.

I guess we could summon @quinnj for comment.

I don’t know the implementation details too well, but there could maybe also be a stage before mapping 1:1 to fields. Somehow JSON3.jl got it done as well, and I don’t see it mentioned anywhere that JSON.jl v1 intentionally removed that feature.

Is the overall code example written out of order, or if not, what is MyStruct2 at this point?

JSON3.read("""{"a": 42}""", MyStruct) throws MethodError: no method matching MyStruct(::Int64, ::Nothing) for me.

Yeah sorry, my first example is wrong. I got confused between half a dozen ways in which it might work. How I used to address this problem with JSON3:

struct MyStruct
    a::Int
    b::String
    MyStruct(a::Int, ::Nothing = nothing) = new(a, string(a))
end

then

julia> JSON3.read("""{"a": 42}""", MyStruct)
MyStruct(42, "42")

but still

julia> JSON.parse("""{"a": 42}""", MyStruct)
ERROR: TypeError: in typeassert, expected String, got a value of type Nothing
...