Repository: matthijscox/JSONSchemaGenerator.jl
Pull request #7: Added support for boolean combination keywords
Author: login: NickSale
Created: 2025-05-13T10:58:53Z | Merged: 2025-05-16T16:18:59Z

Was wanting to use the JSON boolean combination keywords (https://json-schema.org/understanding-json-schema/reference/combining#boolean-json-schema-combination) in a schema but still have the schema automatically generated.

Main changes:
- Added new types AllOf{T,S}, AnyOf{T,S}, OneOf{T,S}, Not{T} whose use in a struct allows the schema generator to add the corresponding keywords
- Made it so that the schema generator will interpret Val types in structs as JSON const values (e.g. x::Val{true} -> x : { const : true })

Included an extra test making use of these changes.

Let me know if you think there's a nicer way to do this!


Diff:
diff --git a/Project.toml b/Project.toml
index e1a14d1..6ab489a 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,7 +1,7 @@
 name = "JSONSchemaGenerator"
 uuid = "b10a6b5e-eaef-4b72-b159-8e5005e98e8e"
 authors = ["matthijscox <matthijs.cox@gmail.com> and contributors"]
-version = "0.2.0"
+version = "0.3.0"
 
 [deps]
 Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
@@ -22,4 +22,4 @@ JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692"
 Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
 
 [targets]
-test = ["Test", "JSON", "JSON3", "JSONSchema"]
+test = ["Test", "JSON", "JSON3", "JSONSchema"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 6c6f791..b48b23a 100644
--- a/README.md
+++ b/README.md
@@ -150,4 +150,111 @@ obj = NestedFieldSchema(
 json_dict = JSON3.write(obj) |> JSON.parse
 
 JSONSchema.validate(JSONSchema.Schema(schema_dict), json_dict) === nothing
-```
\ No newline at end of file
+```
+
+## Boolean Combination Keywords
+
+JSONSchemaGenerator.jl provides a function `combinationkeywords(::Type)` which can be used to associate a struct with an array of special types `AllOf{T,S}`, `AnyOf{T,S}`, `OneOf{T,S}` and `Not{T}` that allow the corresponding JSON keyword to be generated in a schema (see [Boolean JSON Schema combination](https://json-schema.org/understanding-json-schema/reference/combining)). Note that more than two schemas can be combined by chaining: e.g. `AllOf{A, AllOf{B, C}}`.
+
+In the following example we combine some schemas that check if fields are equal to specific values (using `Val` and `Tuple` types, noting that these do not serialize well and should only be used for validation purposes like this):
+```julia
+import JSONSchemaGenerator as JSG
+using JSONSchema, JSON3
+
+struct ConstantInt1Schema
+    int::Val{1}
+end
+
+struct EnumInt2Or3Schema
+    int::Tuple{2,3}
+end
+
+struct ConstantBoolTrueSchema
+    bool::Val{true}
+end
+
+struct BooleanCombinationSchema
+    int::Int
+    bool::Bool
+end
+StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
+JSG.combinationkeywords(::Type{BooleanCombinationSchema}) = [
+    JSG.AllOf{
+        JSG.AnyOf{ConstantInt1Schema, EnumInt2Or3Schema},
+        JSG.Not{ConstantBoolTrueSchema}
+    }
+]
+
+schema_dict = JSG.schema(BooleanCombinationSchema)
+
+good_json = JSON3.write(BooleanCombinationSchema(2, false))
+bad_json = JSON3.write(BooleanCombinationSchema(5, true))
+
+JSONSchema.validate(JSONSchema.Schema(schema_dict), good_json) === nothing
+JSONSchema.validate(JSONSchema.Schema(schema_dict), bad_json) !== nothing
+```
+
+The printed schema looks as follows:
+```julia
+julia> JSON.print(schema_dict, 2)
+JSON.print(schema_dict, 2)
+{
+  "type": "object",
+  "properties": {
+    "int": {
+      "type": "integer"
+    },
+    "bool": {
+      "type": "boolean"
+    }
+  },
+  "required": [
+    "int",
+    "bool"
+  ],
+  "allOf": [
+    {
+      "anyOf": [
+        {
+          "type": "object",
+          "properties": {
+            "int": {
+              "const": 1
+            }
+          },
+          "required": [
+            "int"
+          ]
+        },
+        {
+          "type": "object",
+          "properties": {
+            "int": {
+              "enum": [
+                2,
+                3
+              ]
+            }
+          },
+          "required": [
+            "int"
+          ]
+        }
+      ]
+    },
+    {
+      "not": {
+        "type": "object",
+        "properties": {
+          "bool": {
+            "const": true
+          }
+        },
+        "required": [
+          "bool"
+        ]
+      }
+    }
+  ]
+}
+```
diff --git a/src/CombinationKeywordTypes.jl b/src/CombinationKeywordTypes.jl
new file mode 100644
index 0000000..78c88e3
--- /dev/null
+++ b/src/CombinationKeywordTypes.jl
@@ -0,0 +1,75 @@
+"""
+    AllOf{T}
+
+Type introduced for generating the JSON schema `allOf` keyword.
+
+Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
+    or chained with other keywords.
+
+# Example
+```julia
+combinationkeywords(MyStructType) = (AllOf{StructTypeA, StructTypeB}, Not{AllOf{StructTypeC, StructTypeD}})
+```
+"""
+struct AllOf{T,S} end
+
+"""
+    AnyOf{T}
+
+Type introduced for generating the JSON schema `anyOf` keyword.
+
+Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
+    or chained with other keywords.
+
+# Example
+```julia
+combinationkeywords(MyStructType) = (AnyOf{StructTypeA, StructTypeB}, Not{AnyOf{StructTypeC, StructTypeD}})
+```
+"""
+struct AnyOf{T,S} end
+
+"""
+    OneOf{T}
+
+Type introduced for generating the JSON schema `oneOf` keyword.
+
+Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
+    or chained with other keywords.
+
+# Example
+```julia
+combinationkeywords(MyStructType) = (OneOf{StructTypeA, StructTypeB}, Not{OneOf{StructTypeC, StructTypeD}})
+```
+"""
+struct OneOf{T,S} end
+
+"""
+    Not{T}
+
+Type introduced for generating the JSON schema `not` keyword.
+
+Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
+    or chained with other keywords.
+
+# Example
+```julia
+combinationkeywords(MyStructType) = (Not{StructA}, AnyOf{Not{StructB}, StructC})
+```
+"""
+struct Not{T} end
+
+"""
+    combinationkeywords(T::Type)::Tuple
+
+Specifies which JSON boolean combination keywords will be included in the generated schema for a type.
+
+Elements should be one of the following types: `AllOf{T,S}`, `AnyOf{T,S}`, `OneOf{T,S}`, `Not{T}`.
+
+# Example
+```julia
+combinationkeywords(MyStructType) = (AllOf{SchemaA, SchemaB}, Not{SchemaC})
+```
+"""
+function combinationkeywords end
+
+combinationkeywords(::Type) = ()
\ No newline at end of file
diff --git a/src/JSONSchemaGenerator.jl b/src/JSONSchemaGenerator.jl
index cc67c51..fd9c36b 100644
--- a/src/JSONSchemaGenerator.jl
+++ b/src/JSONSchemaGenerator.jl
@@ -8,6 +8,8 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+include("CombinationKeywordTypes.jl")
+
 # by default we assume the type is a custom type, which should be a JSON object
 _json_type(::Type{<:Any}) = :object
 #_json_type(::Type{<:AbstractDict}) = :object
@@ -26,6 +28,12 @@ _json_type(::Type{Base.UUID}) = :string
 _json_type(::Type{T}) where {T <: Dates.TimeType} = :string
 _json_type(::Type{VersionNumber}) = :string
 _json_type(::Type{Base.Regex}) = :string
+_json_type(::Type{<:Val}) = :const
+_json_type(::Type{<:Tuple}) = :enum
+_json_type(::Type{<:AllOf}) = :keyword
+_json_type(::Type{<:AnyOf}) = :keyword
+_json_type(::Type{<:OneOf}) = :keyword
+_json_type(::Type{<:Not}) = :keyword
 
 _is_nothing_union(::Type) = false
 _is_nothing_union(::Type{Nothing}) = false
@@ -130,6 +138,12 @@ function _generate_json_object(julia_type::Type, settings::SchemaSettings)
     if is_top_level && settings.use_references
         d["\$defs"] = _generate_json_reference_types(settings)
     end
+    for combination_type in combinationkeywords(julia_type)
+        _json_type(combination_type) == :keyword ? nothing : error("combinationkeywords($julia_type) should only contain valid keywords")
+        keyword_dict = _generate_json_type_def(combination_type, settings)
+        issubset(keys(keyword_dict), keys(d)) ? error("each keyword should only appear at most once in combinationkeywords($julia_type)") : nothing
+        merge!(d, keyword_dict)
+    end
     return d
 end
 
@@ -154,12 +168,48 @@ function _generate_json_type_def(::Val{:array}, julia_type::Type{<:AbstractArray
     )
 end
 
+function _generate_json_type_def(::Val{:const}, julia_type::Type{<:Val}, settings::SchemaSettings)
+    return settings.dict_type{String, Any}(
+        "const" => julia_type.parameters[1]
+    )
+end
+
+function _generate_json_type_def(::Val{:enum}, julia_type::Type{<:Tuple}, settings::SchemaSettings)
+    return settings.dict_type{String, Any}(
+        "enum" => [p isa Symbol ? String(p) : p for p in julia_type.parameters]
+    )
+end
+
 function _generate_json_type_def(::Val{:enum}, julia_type::Type, settings::SchemaSettings)
     return settings.dict_type{String, Any}(
         "enum" => string.(instances(julia_type))
     )
 end
 
+function _generate_json_type_def(::Val{:keyword}, julia_type::Type{AllOf{T,S}}, settings::SchemaSettings) where {T,S}
+    return settings.dict_type{String, Any}(
+        "allOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
+    )
+end
+
+function _generate_json_type_def(::Val{:keyword}, julia_type::Type{AnyOf{T,S}}, settings::SchemaSettings) where {T,S}
+    return settings.dict_type{String, Any}(
+        "anyOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
+    )
+end
+
+function _generate_json_type_def(::Val{:keyword}, julia_type::Type{OneOf{T,S}}, settings::SchemaSettings) where {T,S}
+    return settings.dict_type{String, Any}(
+        "oneOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
+    )
+end
+
+function _generate_json_type_def(::Val{:keyword}, julia_type::Type{Not{T}}, settings::SchemaSettings) where {T}
+    return settings.dict_type{String, Any}(
+        "not" => _generate_json_type_def(T, settings)
+    )
+end
+
 function _generate_json_type_def(::Val, julia_type::Type, settings::SchemaSettings)
     return settings.dict_type{String, Any}(
         "type" => string(_json_type(julia_type))
diff --git a/test/runtests.jl b/test/runtests.jl
index 4383adf..b7e0281 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -9,6 +9,8 @@ using StructTypes
 module TestTypes
     using Dates
     using StructTypes
+    import JSONSchemaGenerator
+    const JSG = JSONSchemaGenerator
 
     struct BasicSchema
         int::Int64
@@ -84,6 +86,42 @@ module TestTypes
             NestedSchema(),
         )
     end
+
+    struct ConstantInt1Schema
+        int::Val{1}
+    end
+    struct EnumInt2Or3Schema
+        int::Tuple{2,3}
+    end
+    struct ConstantBoolTrueSchema
+        bool::Val{true}
+    end
+    struct BooleanCombinationSchema
+        int::Int
+        bool::Bool
+    end
+    JSG.combinationkeywords(::Type{BooleanCombinationSchema}) = [
+        JSG.AllOf{
+            JSG.AnyOf{ConstantInt1Schema, EnumInt2Or3Schema},
+            JSG.Not{ConstantBoolTrueSchema}
+        }
+    ]
+    StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
+
+    struct BadBooleanCombinationSchema
+        int::Int
+    end
+    StructTypes.StructType(::Type{BadBooleanCombinationSchema}) = StructTypes.Struct()
+    JSG.combinationkeywords(::Type{BadBooleanCombinationSchema}) = [
+        JSG.AllOf{ConstantInt1Schema, ConstantInt1Schema},
+        JSG.AllOf{EnumInt2Or3Schema, EnumInt2Or3Schema}
+    ]
+
+    struct BadBooleanCombinationSchema2
+        int::Int
+    end
+    StructTypes.StructType(::Type{BadBooleanCombinationSchema2}) = StructTypes.Struct()
+    JSG.combinationkeywords(::Type{BadBooleanCombinationSchema2}) = [Int32]
 end
 
 function test_json_schema_validation(obj::T) where T
@@ -269,3 +307,28 @@ end
     json_schema = JSONSchemaGenerator.schema(TestTypes.NestedSchema, use_references=true, dict_type=Dict)
     test_json_schema_validation(json_schema, TestTypes.NestedSchema())
 end
+
+@testset "Boolean Combination of Schemas" begin
+    @testset "Good weather" begin
+        combo_schema = JSONSchemaGenerator.schema(TestTypes.BooleanCombinationSchema)
+        constantint1_schema = JSONSchemaGenerator.schema(TestTypes.ConstantInt1Schema)
+        enumint2or3_schema = JSONSchemaGenerator.schema(TestTypes.EnumInt2Or3Schema)
+        constantbooltrue_schema = JSONSchemaGenerator.schema(TestTypes.ConstantBoolTrueSchema)
+
+        @test combo_schema["allOf"][1]["anyOf"][1] == constantint1_schema
+        @test combo_schema["allOf"][1]["anyOf"][2] == enumint2or3_schema
+        @test combo_schema["allOf"][2]["not"] == constantbooltrue_schema
+
+        test_json_schema_validation(TestTypes.BooleanCombinationSchema(1, false))
+        test_json_schema_validation(TestTypes.BooleanCombinationSchema(2, false))
+        test_json_schema_validation(TestTypes.BooleanCombinationSchema(3, false))
+    end
+
+    @testset "Multiple uses of same keyword in one object" begin
+        @test_throws Exception bad_combo_schema = JSONSchemaGenerator.schema(TestTypes.BadBooleanCombinationSchema)
+    end
+
+    @testset "Use of incorrect type in combinationkeywords" begin
+        @test_throws Exception bad_combo_schema2 = JSONSchemaGenerator.schema(TestTypes.BadBooleanCombinationSchema2)
+    end
+end


Reviews:
user: login: matthijscox
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-15T14:00:24Z
user: login: NickSale
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T14:07:52Z
user: login: NickSale
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T14:08:21Z
user: login: matthijscox
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-15T14:15:41Z
user: login: matthijscox
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-15T14:21:03Z
user: login: NickSale
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T14:25:09Z
user: login: NickSale
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T15:20:05Z
user: login: NickSale
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T15:26:26Z

Review comments:
diff_hunk: @@ -9,6 +9,7 @@ using StructTypes
 module TestTypes
     using Dates
     using StructTypes
+    import JSONSchemaGenerator as JSG
path: test/runtests.jl
user: login: matthijscox
body: the `import X as Y` syntax doesnt work in old Julia versions. To make the tests work on old Julia versions I think you can do:
```julia
import JSONSchemaGenerator 
const JSG = JSONSchemaGenerator 
```
created_at: 2025-05-14T09:24:07Z
author_association: OWNER
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: matthijscox
body: make the default a tuple `()`, it's generally more optimal than using an array (can be compiled away I think, while arrays cannot)

Maybe we should also add a docstring and export this function, since it's now a formal interface.
The same holds for `AllOf`, `AnyOf`, `OneOf` and `Not` (although `Not` may conflict with InvertedIndices.Not)
created_at: 2025-05-15T13:53:26Z
author_association: OWNER
diff_hunk: @@ -9,6 +9,7 @@ using StructTypes
 module TestTypes
     using Dates
     using StructTypes
+    import JSONSchemaGenerator as JSG
path: test/runtests.jl
user: login: NickSale
body: this was fixed in a previous commit
created_at: 2025-05-15T14:07:52Z
author_association: NONE
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: NickSale
body: Good one, what do you think of calling them JSONNot, JSONAllOf, etc?
created_at: 2025-05-15T14:08:21Z
author_association: NONE
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: matthijscox
body: Hmm, no that feels ugly :)

We can also choose just not to export for now, or accept a possible clash.
created_at: 2025-05-15T14:15:41Z
author_association: OWNER
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: matthijscox
body: Actually StructTypes.jl also doesn't export anything, but it does document all the 'public' types and functions
created_at: 2025-05-15T14:21:02Z
author_association: OWNER
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: NickSale
body: Can add some docstrings then
created_at: 2025-05-15T14:25:09Z
author_association: NONE
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: NickSale
body: Added!
created_at: 2025-05-15T15:20:05Z
author_association: NONE
diff_hunk: @@ -8,6 +8,15 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
     fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
 end
 
+# support boolean combinations of schemas by supplying the following types for use in structs
+struct AllOf{T,S} end
+struct AnyOf{T,S} end
+struct OneOf{T,S} end
+struct Not{T} end
+
+# add use of the above keywords to a struct by specifying a list of them to use with combinationkeywords
+combinationkeywords(::Type) = []
path: src/JSONSchemaGenerator.jl
user: login: NickSale
body: > We can also choose just not to export for now

I think this is fine. Then using these types and functions helps make it clear what they are doing, i.e. something to do with schema generation
created_at: 2025-05-15T15:26:26Z
author_association: NONE

PR comments:
user: login: MichaelHatherly
created_at: 2025-05-13T12:33:31Z
body: This would be really nice to have, I've got some local hacks to do similar for `anyOf`, but they're definitely not generally useful or worth sharing yet.

How does this integrate with `JSON3.read`/`JSON3.write`? Can you (de)serialise into types that make use of these new structs you've defined here?
author_association: CONTRIBUTOR
user: login: NickSale
created_at: 2025-05-13T12:59:08Z
body: > How does this integrate with `JSON3.read`/`JSON3.write`? Can you (de)serialise into types that make use of these new structs you've defined here?

Good question!
For write: as presented, you end up with something along the lines of "JSG.AllOf{JSG.AnyOf{ConstantInt1Schema, ConstantInt2Schema}, JSG.Not{ConstantBoolTrueSchema}}()" in your output JSON string which is not very helpful.
For read: providing a default value for allOf, etc in your struct avoids the need to have such an element in your input JSON.

I think the best bet would be to add allOf, etc to StructTypes.excludes, avoiding writing and reading the element at all. You only really want it to show up in the generated schema.
author_association: NONE
user: login: NickSale
created_at: 2025-05-13T14:12:44Z
body: > How does this integrate with `JSON3.read`/`JSON3.write`? Can you (de)serialise into types that make use of these new structs you've defined here?

Ah now I realise you perhaps mean deserializing JSON that has these keywords into automatically generated types that use these AllOf{T,S} etc structs? That's not something I've really thought about, although I would guess it would be fairly straightforward? But my use case was only for the reverse problem (types->schema) for now!

author_association: NONE
user: login: MichaelHatherly
created_at: 2025-05-13T14:33:05Z
body: > Ah now I realise you perhaps mean deserializing JSON that has these keywords into automatically generated types that use these AllOf{T,S} etc structs?

Yes, that's my main use case.

I want to be able to define some Julia structs, probably nested several layers, and generate a JSON schema that when you happen to generate a valid JSON object for that schema you can directly call `JSON3.read("...", MyStruct)` on it.
author_association: CONTRIBUTOR
user: login: matthijscox
created_at: 2025-05-13T14:39:03Z
body: I agree the full StructTypes integration would be optimal. Or at least make sure we don't block that path forward for a next PR (does it right now?).

I'd also love to see maybe an example string of such a schema. I always forget how JSON schemas actually work :) Nice to add to the readme maybe too if it's not too large.

And given the growing readme we should consider making proper docs. But I can make that a separate issue of it's too much work.

author_association: OWNER
user: login: NickSale
created_at: 2025-05-13T15:33:28Z
body: > I want to be able to define some Julia structs, probably nested several layers, and generate a JSON schema that when you happen to generate a valid JSON object for that schema you can directly call `JSON3.read("...", MyStruct)` on it.

Ah well that's indeed possible here! That's what we're doing with it.

I've made an update to enforce adding any extra 'keyword' properties of structs to StructTypes.excludes so they definitely don't get in the way of deserializing.

What I thought you meant, and is not possible (yet, and I'm not sure is really a good idea) is to generate the types themselves automatically in the sense of https://quinnj.github.io/JSON3.jl/stable/#Generated-Types.



> I'd also love to see maybe an example string of such a schema. I always forget how JSON schemas actually work :) Nice to add to the readme maybe too if it's not too large.

Good idea, will do that tomorrow!


author_association: NONE
user: login: NickSale
created_at: 2025-05-14T07:51:39Z
body: Added the JSON string to the readme!

One more note on (de)serializing: the AllOf etc structs don't get in the way, but using Val to get const values in the schema definitely does not work with JSON3. They should only be used for these kinds of validation purposes.
author_association: NONE
user: login: matthijscox
created_at: 2025-05-14T09:12:35Z
body: > One more note on (de)serializing: the AllOf etc structs don't get in the way, but using Val to get const values in the schema definitely does not work with JSON3. They should only be used for these kinds of validation purposes.

Maybe constant values with `Val` could be handled by defining your own [`StructTypes.construct`](https://juliadata.org/StructTypes.jl/stable/#StructTypes.construct) somehow? 
I don't know, it feels like a niche use case to me? Why put constant values in a JSON? 

Given this issue, I would prefer to separate the `Val` types from the `AllOf` types in the readme if possible, to avoid confusing the user. And perhaps mention the fact that `Val` types cannot directly be (de)serialized by JSON3.

Also please version bump to v0.3.0, then I can directly register after this PR is merged. 
author_association: OWNER
user: login: matthijscox
created_at: 2025-05-14T09:21:55Z
body: 
> Maybe constant values with `Val` could be handled by defining your own [`StructTypes.construct`](https://juliadata.org/StructTypes.jl/stable/#StructTypes.construct) somehow? I don't know, it feels like a niche use case to me? Why put constant values in a JSON?
> 
> Given this issue, I would prefer to separate the `Val` types from the `AllOf` types in the readme if possible, to avoid confusing the user. And perhaps mention the fact that `Val` types cannot directly be (de)serialized by JSON3.

Edit: wait your example works fine right? The `Val` types are in the excluded field. I guess we can leave the example as it is. If people put `Val` types in non-excluded fields that wouldn't work, but is a weird corner case.

But do please version bump :)
author_association: OWNER
user: login: NickSale
created_at: 2025-05-14T10:01:36Z
body: Indeed, have added a quick note to the readme that this usage of Val should only really be used for validation purposes.

> Maybe constant values with `Val` could be handled by defining your own [`StructTypes.construct`](https://juliadata.org/StructTypes.jl/stable/#StructTypes.construct) somehow? I don't know, it feels like a niche use case to me? Why put constant values in a JSON?

For reference, the use case I have is that we have a struct containing some feature toggle alongside settings for that feature. These settings should be optional if the toggle is false, but become required if the toggle is true. This can be achieved using anyOf [ schema where feature toggle is off (using toggle : { const : false } ) , schema where feature settings are required ]. (Refactoring could also achieve this without needing anyOf, but unfortunately the flat format where the toggle and settings sit at the same level is fairly set in stone already!)

> Also please version bump to v0.3.0, then I can directly register after this PR is merged.

Done!


author_association: NONE
user: login: matthijscox
created_at: 2025-05-15T12:34:45Z
body: `AnyOf` still feels like a Julia `Union` to me somehow. Which makes me wonder if we could have nicer syntax for these schema options. But I'm going to have to think about that, so let's just go ahead with this design for now.

Also I'm still wondering if we should make this a trait/function instead of a field? Have you considered that?

So instead of this:
```julia
struct BooleanCombinationSchema
    int::Int
    bool::Bool
    allOf::JSG.AllOf{
        JSG.AnyOf{ConstantInt1Schema, ConstantInt2Schema},
        JSG.Not{ConstantBoolTrueSchema}
    }
end
StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
StructTypes.excludes(::Type{BooleanCombinationSchema}) = (:allOf,) 
```

What if we did this?
```julia
struct BooleanCombinationSchema
    int::Int
    bool::Bool
end
StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
    
function JSG.combination(::Type{BooleanCombinationSchema}) = 
  return JSG.AllOf{
          JSG.AnyOf{ConstantInt1Schema, ConstantInt2Schema},
          JSG.Not{ConstantBoolTrueSchema}
      }
end
```
Now it's clear this is never ever a field that will get (de)serialized by JSON3 struct types, it's a schema thing, like the `"requires"` field which is also not part of the struct. 
author_association: OWNER
user: login: NickSale
created_at: 2025-05-15T12:42:50Z
body: Oh I like that! I'll give it a try and see how it goes

I did it with fields so the julia struct more closely matches the JSON schema you get out, but it introduces the fiddly need to add to excludes and potentially define a new constructor to hide the keyword fields away.
author_association: NONE
user: login: NickSale
created_at: 2025-05-15T13:34:20Z
body: Much nicer for the user and also made the implementation cleaner, good idea!

Could you please rerun the github tests, thanks
author_association: NONE
user: login: matthijscox
created_at: 2025-05-15T13:46:27Z
body: Yeah I like it the more I think about it. This way people can also generate their schema from existing structs if they want, without importing JSONSchemaGenerator into their code and editing those structs.
author_association: OWNER
user: login: NickSale
created_at: 2025-05-15T13:50:12Z
body: > `AnyOf` still feels like a Julia `Union` to me somehow. Which makes me wonder if we could have nicer syntax for these schema options. But I'm going to have to think about that, so let's just go ahead with this design for now.

In principle we only need to specify the types we want to combine and the fact that we want the anyOf keyword. In fact, now I think about it, this trait-based approach also lets us solve the problem of providing an arbitrary number of schemas to combine (instead of the current 2) by instead providing an object that has an array of types to combine.

Could even do something very generic to provide keywords along the lines of
```julia
struct ArrayKeyword   # for making things like anyOf : []
 string keyword
 Type[] subschemas
end
struct SingletonKeyword   # for making things like not : {}, or maybe even const : {}
 string keyword
 Type subschema
end
JSG.keywords(::Type)::Union{SingletonKeyword, ArrayKeyword}[]
```

But for now I think the specific AnyOf, etc types make it clearer how to use them and more easily avoid incorrect schemas, ~~but I will update these to hold the types to combine as fields rather than parameters.~~ -> this make the logic quite messy
author_association: NONE
user: login: NickSale
created_at: 2025-05-15T15:23:40Z
body: Looking into Tuples made me realise that we can implement validation against enums as well as consts. Have updated the example and tests.

The point here that differentiates this use from a properly defined (and serializable) enum is that we can include things like numbers or reserved keywords like true/false.
author_association: NONE
user: login: matthijscox
created_at: 2025-05-16T16:18:40Z
body: Well everything looks good to me, let's merge and I'll register afterwards.
author_association: OWNER