Skip to content

JSONSchema transformation of discriminated unions behaves differently from Zodย #1246

@newageoflight

Description

@newageoflight

As requested by @opqdonut

I've noticed that Malli's JSONSchema transformation behaviour differs from Zod's, which causes problems with LLM structured output APIs (at the moment I don't think any of them support oneOf so they just return 'nil'). Zod outputs an anyOf with the discriminator key required, whereas Malli outputs a oneOf (also with the discriminator required). Originally I was wondering if there was a way to change the behaviour by specifying an argument to (json-schema/transform) but I'm currently working around this by annotating the :multi type with a :json-schema/type attribute and closing the schema before requesting structured output.

Outputs below.

Zod:

const TimePrecision = z.enum([
  "decade",
  "year",
  "quarter",
  "month",
  "week",
  "day",
  "hour",
  "minute",
  "second",
]);

const TimeBase = z.object({
  precision: TimePrecision,
  display: z.string(),
});

const AbsoluteTime = TimeBase.extend({
  temporalType: z.literal("absolute"),
  timestamp: z.iso.datetime(),
}).describe("Absolute temporal reference e.g. 'on Saturday'");

const RelativeTime = TimeBase.extend({
  temporalType: z.literal("relative"),
  anchor: z.iso.datetime(),
  offset: z.iso.duration(),
}).describe("Relative temporal reference e.g. 'last week'");

const RangeTime = TimeBase.extend({
  temporalType: z.literal("range"),
  earliest: z.iso.datetime(),
  latest: z.iso.datetime(),
}).describe("Range time reference e.g. 'some time between 1971 and 1975'");

export const TemporalReferenceStrict = z
  .discriminatedUnion("temporalType", [AbsoluteTime, RelativeTime, RangeTime])
  .meta({
    name: "temporal-reference",
    description:
      "A temporal reference - should only be encoded to the precision that the patient actually describes",
  });

> z.toJSONSchema(TemporalReferenceStrict)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  name: "temporal-reference",
  description: "A temporal reference",
  anyOf: [
    {
      description: "Absolute temporal reference e.g. 'on Saturday'",
      type: "object",
      properties: {
        precision: { type: "string", enum: [Array] },
        display: { type: "string" },
        temporalType: { type: "string", const: "absolute" },
        timestamp: {
          type: "string",
          format: "date-time",
          pattern: "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
        }
      },
      required: [ "precision", "display", "temporalType", "timestamp" ],
      additionalProperties: false
    },
    {
      description: "Relative temporal reference e.g. 'last week'",
      type: "object",
      properties: {
        precision: { type: "string", enum: [Array] },
        display: { type: "string" },
        temporalType: { type: "string", const: "relative" },
        anchor: {
          type: "string",
          format: "date-time",
          pattern: "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
        },
        offset: {
          type: "string",
          format: "duration",
          pattern: "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$"
        }
      },
      required: [ "precision", "display", "temporalType", "anchor", "offset" ],
      additionalProperties: false
    },
    {
      description: "Range time reference e.g. 'some time between 1971 and 1975'",
      type: "object",
      properties: {
        precision: { type: "string", enum: [Array] },
        display: { type: "string" },
        temporalType: { type: "string", const: "range" },
        earliest: {
          type: "string",
          format: "date-time",
          pattern: "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
        },
        latest: {
          type: "string",
          format: "date-time",
          pattern: "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
        }
      },
      required: [ "precision", "display", "temporalType", "earliest", "latest" ],
      additionalProperties: false
    }
  ]
}

Malli:

(def precision
  [:enum {:description "Granularity of the temporal reference"}
   :decade :year :quarter :month :week :day :hour :minute :second :millisecond])

(def ^:private time-base
  [:map
   [:precision precision]
   [:display [:string {:min 1 :description "Original free-form temporal description (e.g. \"Saturday 12 May 2025\", \"last week\")"}]]])

(def reference
  [:multi {:dispatch :temporal-type
              :description "Temporal reference encoded as absolute, range, or relative representation"}
   [:absolute
    [:merge time-base
     [:map
      [:temporal-type [:enum :absolute]]
      [:timestamp :iso-partial]]]]
   [:relative
    [:merge time-base
     [:map
      [:temporal-type [:enum :relative]]
      [:anchor {:description "Anchor of the temporal reference - either a datetime or a named event"}
       [:or :iso-partial :uuid-v7]]
      [:offset :iso-duration]]]]
   [:range
    [:map
     [:temporal-type [:enum :range]]
     [:display [:string {:min 1 :description "Original free-form temporal description (e.g. \"Saturday 12 May 2025\", \"last week\")"}]]
     [:earliest :iso-partial]
     [:earliest-precision precision]
     [:latest :iso-partial]
     [:latest-precision precision]]]])

(json-schema/transform reference)

{:description "Temporal reference encoded as absolute, range, or relative representation",
 :oneOf
 [{:type "object",
   :properties
   {:precision
    {:description "Granularity of the temporal reference",
     :type "string",
     :enum [:decade :year :quarter :month :week :day :hour :minute :second :millisecond]},
    :display
    {:description "Original free-form temporal description (e.g. \"Saturday 12 May 2025\", \"last week\")",
     :type "string",
     :minLength 1},
    :temporal-type {:type "string", :enum [:absolute]},
    :timestamp {:type "string"}},
   :required [:precision :display :temporal-type :timestamp]}
  {:type "object",
   :properties
   {:precision
    {:description "Granularity of the temporal reference",
     :type "string",
     :enum [:decade :year :quarter :month :week :day :hour :minute :second :millisecond]},
    :display
    {:description "Original free-form temporal description (e.g. \"Saturday 12 May 2025\", \"last week\")",
     :type "string",
     :minLength 1},
    :temporal-type {:type "string", :enum [:relative]},
    :anchor
    {:description "Anchor of the temporal reference - either a datetime or a named event",
     :anyOf [{:type "string"} {:type "string", :format "uuid"}]},
    :offset {:anyOf [{:$ref "#/definitions/time.duration"} {:$ref "#/definitions/time.period"}]}},
   :required [:precision :display :temporal-type :anchor :offset]}
  {:type "object",
   :properties
   {:temporal-type {:type "string", :enum [:range]},
    :display
    {:description "Original free-form temporal description (e.g. \"Saturday 12 May 2025\", \"last week\")",
     :type "string",
     :minLength 1},
    :earliest {:type "string"},
    :earliest-precision
    {:description "Granularity of the temporal reference",
     :type "string",
     :enum [:decade :year :quarter :month :week :day :hour :minute :second :millisecond]},
    :latest {:type "string"},
    :latest-precision
    {:description "Granularity of the temporal reference",
     :type "string",
     :enum [:decade :year :quarter :month :week :day :hour :minute :second :millisecond]}},
   :required [:temporal-type :display :earliest :earliest-precision :latest :latest-precision]}],
 :definitions {"time.duration" {:type "string", :format "duration"}, "time.period" {}}}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status

    ๐Ÿ‘ Could do

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions