Guide

Annotating Types

Every type that you want to generate a schema for needs an annotate method that returns an Annotation. The annotation carries a name, description, and per-field metadata.

using DescribedTypes
using JSON

struct Weather
    location::String
    temperature::Float64
    unit::String
end

DescribedTypes.annotate(::Type{Weather}) = Annotation(
    name="Weather",
    description="Current weather observation.",
    parameters=Dict(
        :location    => Annotation(name="location", description="City name"),
        :temperature => Annotation(name="temperature", description="Temperature value"),
        :unit        => Annotation(name="unit", description="Unit of measurement", enum=["celsius", "fahrenheit"]),
    ),
)

If you don't define annotate for a type, a default annotation is generated using the type name, with generic field descriptions.

Generating Schemas

Use schema to produce a JSON Schema dictionary.

Plain JSON Schema (STANDARD):

d = schema(Weather)
print(JSON.json(d, 2))
{
  "type": "object",
  "properties": {
    "location": {
      "type": "string"
    },
    "temperature": {
      "type": "number"
    },
    "unit": {
      "type": "string"
    }
  },
  "required": [
    "location",
    "temperature",
    "unit"
  ]
}

OpenAI response-format wrapper (OPENAI):

d = schema(Weather, llm_adapter=OPENAI)
print(JSON.json(d, 2))
{
  "name": "Weather",
  "description": "Current weather observation.",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "City name"
      },
      "temperature": {
        "type": "number",
        "description": "Temperature value"
      },
      "unit": {
        "type": "string",
        "description": "Unit of measurement",
        "enum": [
          "celsius",
          "fahrenheit"
        ]
      }
    },
    "required": [
      "location",
      "temperature",
      "unit"
    ],
    "additionalProperties": false
  }
}

OpenAI function/tool-calling wrapper (OPENAI_TOOLS):

d = schema(Weather, llm_adapter=OPENAI_TOOLS)
print(JSON.json(d, 2))
{
  "type": "function",
  "name": "Weather",
  "description": "Current weather observation.",
  "strict": true,
  "parameters": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "City name"
      },
      "temperature": {
        "type": "number",
        "description": "Temperature value"
      },
      "unit": {
        "type": "string",
        "description": "Unit of measurement",
        "enum": [
          "celsius",
          "fahrenheit"
        ]
      }
    },
    "required": [
      "location",
      "temperature",
      "unit"
    ],
    "additionalProperties": false
  }
}

Optional Fields

Fields typed as Union{Nothing, T} are treated as optional:

  • In STANDARD mode they are omitted from the "required" array.
  • In OPENAI / OPENAI_TOOLS modes all fields remain required (per OpenAI spec), but optional fields use ["type", "null"] to allow a null value.
struct Query
    text::String
    max_tokens::Union{Nothing, Int}
end

DescribedTypes.annotate(::Type{Query}) = Annotation(
    name="Query",
    description="A search query.",
    parameters=Dict(
        :text       => Annotation(name="text", description="The query string"),
        :max_tokens => Annotation(name="max_tokens", description="Optional token limit"),
    ),
)

Standard schema (optional fields omitted from required):

print(JSON.json(schema(Query), 2))
{
  "type": "object",
  "properties": {
    "text": {
      "type": "string"
    },
    "max_tokens": {
      "type": "integer"
    }
  },
  "required": [
    "text"
  ]
}

OpenAI schema (optional fields use ["type", "null"]):

print(JSON.json(schema(Query, llm_adapter=OPENAI), 2))
{
  "name": "Query",
  "description": "A search query.",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "text": {
        "type": "string",
        "description": "The query string"
      },
      "max_tokens": {
        "type": [
          "integer",
          "null"
        ],
        "description": "Optional token limit"
      }
    },
    "required": [
      "text",
      "max_tokens"
    ],
    "additionalProperties": false
  }
}

Enum Fields

There are two ways to represent enums:

1. Julia @enum types

Automatically serialised to their string representations:

@enum Color red green blue

struct Palette
    primary::Color
end

print(JSON.json(schema(Palette), 2))
{
  "type": "object",
  "properties": {
    "primary": {
      "type": "string",
      "enum": [
        "red",
        "green",
        "blue"
      ]
    }
  },
  "required": [
    "primary"
  ]
}

You can also annotate the enum field with a description — the enum values are still inferred from the Julia type, so you don't need to repeat them:

DescribedTypes.annotate(::Type{Palette}) = Annotation(
    name="Palette",
    description="A color palette.",
    parameters=Dict(
        :primary => Annotation(name="primary", description="The primary color"),
    ),
)

print(JSON.json(schema(Palette, llm_adapter=OPENAI), 2))
{
  "name": "Palette",
  "description": "A color palette.",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "primary": {
        "type": "string",
        "enum": [
          "red",
          "green",
          "blue"
        ],
        "description": "The primary color"
      }
    },
    "required": [
      "primary"
    ],
    "additionalProperties": false
  }
}

2. String fields with enum annotations

Constrain allowed values via the enum keyword in Annotation:

struct Shirt
    color::String
end

DescribedTypes.annotate(::Type{Shirt}) = Annotation(
    name="Shirt",
    description="A shirt.",
    parameters=Dict(
        :color => Annotation(name="color", description="Shirt color", enum=["red", "green", "blue"]),
    ),
)

print(JSON.json(schema(Shirt, llm_adapter=OPENAI), 2))
{
  "name": "Shirt",
  "description": "A shirt.",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "color": {
        "type": "string",
        "description": "Shirt color",
        "enum": [
          "red",
          "green",
          "blue"
        ]
      }
    },
    "required": [
      "color"
    ],
    "additionalProperties": false
  }
}
Note

The enum keyword in annotations only takes effect in OpenAI modes (OPENAI / OPENAI_TOOLS). In STANDARD mode it is ignored.

You can also use symbols in the annotation enum values:

DescribedTypes.annotate(::Type{Shirt}) = Annotation(
    name="Shirt",
    description="A shirt.",
    parameters=Dict(
        :color => Annotation(name="color", description="Shirt color", enum=[:red, :green, :blue]),
    ),
)

print(JSON.json(schema(Shirt, llm_adapter=OPENAI), 2))
{
  "name": "Shirt",
  "description": "A shirt.",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "color": {
        "type": "string",
        "description": "Shirt color",
        "enum": [
          "red",
          "green",
          "blue"
        ]
      }
    },
    "required": [
      "color"
    ],
    "additionalProperties": false
  }
}

Duplicate handling is configurable when generating schema:

# default: :dedupe
schema(Shirt, llm_adapter=OPENAI)

# strict: error on duplicates after normalization
# schema(Shirt, llm_adapter=OPENAI, enum_duplicate_policy=:error)
JSON.Object{String, Any} with 4 entries:
  "name"        => "Shirt"
  "description" => "A shirt."
  "strict"      => true
  "schema"      => Object{String, Any}("type"=>"object", "properties"=>Object{S…

Nested Types

Nested structs are expanded inline by default:

struct Address
    street::String
    city::String
end

struct Person
    name::String
    address::Address
end

print(JSON.json(schema(Person), 2))
{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "address": {
      "type": "object",
      "properties": {
        "street": {
          "type": "string"
        },
        "city": {
          "type": "string"
        }
      },
      "required": [
        "street",
        "city"
      ]
    }
  },
  "required": [
    "name",
    "address"
  ]
}

Schema References

For deeply nested or repeated types, pass use_references=true to factor shared types into $defs and reference them via $ref:

print(JSON.json(schema(Person, use_references=true), 2))
{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "address": {
      "$ref": "#/$defs/Main.Address",
      "description": "Semantic of address in the context of the schema"
    }
  },
  "required": [
    "name",
    "address"
  ],
  "$defs": {
    "Main.Address": {
      "type": "object",
      "properties": {
        "street": {
          "type": "string"
        },
        "city": {
          "type": "string"
        }
      },
      "required": [
        "street",
        "city"
      ]
    }
  }
}

Custom Dict Type

By default schemas use JSON.Object (preserves insertion order). You can switch to Dict if order doesn't matter:

d = schema(Person, dict_type=Dict)
typeof(d)
Dict{String, Any}

Function Method Schemas

DescribedTypes can also extract function-method signatures and generate tool-ready JSON schemas.

"""
Weather lookup helper.
"""
function weather(city::String, days::Int=3; unit::String="celsius", include_humidity::Bool=false)
    return (; city, days, unit, include_humidity)
end

sig = extractsignature(weather)
sig.name, length(sig.args)
(:weather, 4)

You can customize method/argument metadata with MethodAnnotation and ArgAnnotation:

DescribedTypes.annotate(::typeof(weather), ms::MethodSignature) = MethodAnnotation(
    name=:weather_tool,
    description="Weather lookup tool.",
    argsannot=Dict(
        :city => ArgAnnotation(name=:city, description="City name", required=true),
        :days => ArgAnnotation(name=:days, description="Forecast horizon", required=false),
        :unit => ArgAnnotation(name=:unit, description="Temperature unit", enum=["celsius", "fahrenheit"], required=false),
        :include_humidity => ArgAnnotation(name=:include_humidity, description="Include humidity signal", required=false),
    ),
)

Generate an OpenAI tool schema:

print(JSON.json(schema(weather, llm_adapter=OPENAI_TOOLS), 2))
{
  "type": "function",
  "name": "weather_tool",
  "description": "Weather lookup tool.",
  "strict": true,
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "City name"
      },
      "days": {
        "type": [
          "integer",
          "null"
        ],
        "description": "Forecast horizon"
      },
      "unit": {
        "type": [
          "string",
          "null"
        ],
        "description": "Temperature unit",
        "enum": [
          "celsius",
          "fahrenheit"
        ]
      },
      "include_humidity": {
        "type": [
          "boolean",
          "null"
        ],
        "description": "Include humidity signal"
      }
    },
    "required": [
      "city",
      "days",
      "unit",
      "include_humidity"
    ],
    "additionalProperties": false
  }
}

Method Selection (selector)

For functions with multiple methods, pick the exact method using selector. You can pass an index, a Method, or a selector function.

function weather_multi(city::String)
    return (; city, mode="quick")
end

function weather_multi(city::String, days::Int)
    return (; city, days, mode="full")
end

selected = first(methods(weather_multi, (String, Int)))
schema_selected = schema(weather_multi, selector=selected, llm_adapter=OPENAI_TOOLS)
print(JSON.json(schema_selected, 2))
{
  "type": "function",
  "name": "weather_multi",
  "description": "Semantic of weather_multi in the context of function calling",
  "strict": true,
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "Semantic of city in the context of weather_multi"
      },
      "days": {
        "type": [
          "integer",
          "null"
        ],
        "description": "Semantic of days in the context of weather_multi"
      }
    },
    "required": [
      "city",
      "days"
    ],
    "additionalProperties": false
  }
}

Explicit Method Annotation

You can pass method_annotation directly instead of defining annotate(::typeof(fn), ::MethodSignature).

manual_annot = MethodAnnotation(
    name=:weather_manual,
    description="Weather tool with explicit annotation argument.",
    argsannot=Dict(
        :city => ArgAnnotation(name=:city, description="City", required=true),
        :days => ArgAnnotation(name=:days, description="Days", required=false),
        :unit => ArgAnnotation(name=:unit, description="Unit", enum=["celsius", "fahrenheit"], required=false),
        :include_humidity => ArgAnnotation(name=:include_humidity, description="Humidity toggle", required=false),
    ),
)

print(JSON.json(schema(weather, method_annotation=manual_annot, llm_adapter=OPENAI_TOOLS), 2))
{
  "type": "function",
  "name": "weather_manual",
  "description": "Weather tool with explicit annotation argument.",
  "strict": true,
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "City"
      },
      "days": {
        "type": [
          "integer",
          "null"
        ],
        "description": "Days"
      },
      "unit": {
        "type": [
          "string",
          "null"
        ],
        "description": "Unit",
        "enum": [
          "celsius",
          "fahrenheit"
        ]
      },
      "include_humidity": {
        "type": [
          "boolean",
          "null"
        ],
        "description": "Humidity toggle"
      }
    },
    "required": [
      "city",
      "days",
      "unit",
      "include_humidity"
    ],
    "additionalProperties": false
  }
}

JSON → Function Calling

Use callfunction to validate/coerce JSON-style arguments and invoke the Julia function:

res1 = callfunction(weather, Dict("city" => "Paris"))
res2 = callfunction(weather, "{\"city\":\"Berlin\",\"days\":1,\"unit\":\"fahrenheit\"}")
(res1, res2)
((city = "Paris", days = 3, unit = "celsius", include_humidity = false), (city = "Berlin", days = 1, unit = "fahrenheit", include_humidity = false))

OpenAI-style wrapped payloads are also accepted:

callfunction(weather, Dict("arguments" => "{\"city\":\"Rome\",\"unit\":\"celsius\"}"))
(city = "Rome", days = 3, unit = "celsius", include_humidity = false)

You can combine this with method selection for overloaded functions:

callfunction(weather_multi, Dict("city" => "Paris", "days" => 2), selector=selected)
(city = "Paris", days = 2, mode = "full")

Current Function-Extraction Limits

  • Varargs (args...) and keyword splats (kwargs...) are currently rejected.
  • Runtime fallback extraction (used when source is not available through CodeTracking) may not fully recover required-keyword semantics.
  • In OpenAI modes, optional/defaulted function args are emitted as required nullable fields (["type", "null"]) to match strict-tool conventions.