Optional & Union Types in Schemas
Not all parameters are required, and not all accept a single type. An LLM tool often has optional parameters (the user may or may not provide them), nullable fields (a value that can be present or null), and union types (a parameter that accepts one of several distinct types). JSON Schema has limited built-in support for these patterns—most LLM frameworks don't handle oneOf, anyOf, or allOf consistently. This article teaches you proven patterns for representing optional, nullable, and union types in schemas that actually work across LLM platforms.
Optional Parameters
An optional parameter is one the user may or may not provide. In JSON Schema, mark it by omitting it from the required array. The parameter still exists in properties; it's just not mandatory:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keyword (required)"
},
"limit": {
"type": "integer",
"default": 10,
"description": "Max results (optional; default 10)"
},
"sort_by": {
"type": "string",
"enum": ["relevance", "date"],
"description": "Sort order (optional; default relevance)"
}
},
"required": ["query"]
}
Here, query is required; limit and sort_by are optional. The model sees this and will only include them if the user explicitly requests sorting or a different limit.
Best practice: Always include a default value in the schema for optional parameters, and mention the default in the description. This clarifies what happens if the parameter is omitted:
Limit is the maximum number of results (integer, 1–100). Optional;
defaults to 10 if omitted. Example: 25.
Nullable Fields
A nullable field is one that must be present but can have a null value. This is different from optional. To represent this in JSON Schema, most frameworks use type: ["string", "null"] or simply allow null:
{
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "User ID (required)"
},
"notes": {
"type": ["string", "null"],
"description": "Optional notes. Required field but can be null."
}
},
"required": ["user_id", "notes"]
}
Here, notes is in the required array (must be present), but its type allows null. The model knows to send either a string or null.
Caution: Not all LLM frameworks handle type: ["string", "null"] consistently. Test with your specific platform. An alternative is to omit the field from required and document that it's optional:
{
"type": "object",
"properties": {
"notes": {
"type": "string",
"description": "Optional notes. If omitted, treated as null."
}
}
}
Union Types (Multiple Distinct Types)
Union types are trickier. JSON Schema has oneOf, but most LLM platforms don't handle it well. Instead, use one of these patterns:
Pattern 1: Tagged Union (Discriminator Field)
Use a discriminator field to indicate which type is being sent. This is the most reliable pattern:
{
"type": "object",
"properties": {
"notification_type": {
"type": "string",
"enum": ["email", "sms", "push"],
"description": "Type of notification (determines other fields)"
},
"recipient": {
"type": "string",
"description": "Recipient email, phone, or device ID (required)"
},
"email_subject": {
"type": "string",
"description": "Email subject line (required if notification_type is 'email')"
},
"sms_shortcode": {
"type": "string",
"description": "SMS shortcode (required if notification_type is 'sms')"
},
"push_title": {
"type": "string",
"description": "Push notification title (required if notification_type is 'push')"
}
},
"required": ["notification_type", "recipient"]
}
The model sees notification_type and knows which other fields to fill. Your code validates conditionally:
def send_notification(data: dict) -> str:
if data["notification_type"] == "email":
assert "email_subject" in data
# send email
elif data["notification_type"] == "sms":
assert "sms_shortcode" in data
# send SMS
# ...
Pattern 2: Separate Endpoint Functions
Instead of one function with a union, create separate functions:
def send_email(recipient: str, subject: str, body: str) -> str: ...
def send_sms(recipient: str, message: str) -> str: ...
def send_push(device_id: str, title: str, body: str) -> str: ...
Each has its own schema with only relevant parameters. The model chooses the right function based on user intent. This is cleaner and avoids union type complexity.
Pattern 3: Flexible Object Properties
For unions where all variants have the same base fields but differ in optional extras, use optional properties:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (required)"
},
"filter_type": {
"type": "string",
"enum": ["date_range", "category", "price_range"],
"description": "Type of filter (optional)"
},
"start_date": {
"type": "string",
"format": "date",
"description": "Start date (required if filter_type is 'date_range')"
},
"end_date": {
"type": "string",
"format": "date",
"description": "End date (required if filter_type is 'date_range')"
},
"category": {
"type": "string",
"description": "Category name (required if filter_type is 'category')"
},
"min_price": {
"type": "number",
"description": "Minimum price (required if filter_type is 'price_range')"
},
"max_price": {
"type": "number",
"description": "Maximum price (required if filter_type is 'price_range')"
}
},
"required": ["query"]
}
Pattern 4: Use a String Encoding (For Simple Cases)
For simple unions (e.g., either a string OR an integer), you can document both in a single type and validate at runtime:
{
"user_id": {
"type": ["string", "integer"],
"description": "User ID as either a string (username) or integer (numeric ID). Example: 'alice' or 12345."
}
}
Then validate:
def process_user(user_id):
if isinstance(user_id, int):
# Look up by numeric ID
pass
elif isinstance(user_id, str):
# Look up by username
pass
Note: Not all frameworks support type: [...] arrays. Test first.
Complex Example: E-Commerce Order Tool
Here's a realistic schema combining optional and union patterns:
{
"name": "create_order",
"description": "Create a new order with items and delivery info",
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"product_id": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1}
},
"required": ["product_id", "quantity"]
},
"minItems": 1,
"description": "List of items to order (required)"
},
"customer_email": {
"type": "string",
"pattern": "^.+@.+$",
"description": "Customer email address (required)"
},
"delivery_type": {
"type": "string",
"enum": ["pickup", "delivery"],
"description": "Delivery method: 'pickup' or 'delivery' (optional; default pickup)"
},
"pickup_location_id": {
"type": "string",
"description": "Store location ID (required if delivery_type is 'pickup')"
},
"delivery_address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
"state": {"type": "string"},
"zip": {"type": "string"}
},
"required": ["street", "city", "state", "zip"],
"description": "Delivery address (required if delivery_type is 'delivery')"
},
"gift_message": {
"type": ["string", "null"],
"description": "Optional gift message. Omit or set to null if not needed."
}
},
"required": ["items", "customer_email"]
}
}
The validation logic:
def create_order(data: dict) -> dict:
delivery_type = data.get("delivery_type", "pickup")
if delivery_type == "pickup":
assert "pickup_location_id" in data
location = lookup_store(data["pickup_location_id"])
elif delivery_type == "delivery":
assert "delivery_address" in data
address = data["delivery_address"]
validate_address(address)
# Process order...
return {"order_id": "..."}
Key Takeaways
- Optional parameters are omitted from the
requiredarray. Always include adefaultin the schema and description. - Nullable fields must be present but can be
null. Usetype: ["string", "null"](if supported) or document the behavior. - For union types, use a discriminator/tag field to indicate which variant is being sent. This is the most reliable pattern.
- Alternatively, create separate functions for each type instead of one union function.
- Test union schemas with your specific LLM framework—support varies.
Frequently Asked Questions
What's the difference between optional and nullable?
Optional means the field can be omitted entirely (not in required array). Nullable means the field must be present but can have a null value. Omit a field from required for optional; include it but allow type: [..., "null"] for nullable.
Should I use oneOf for unions in LLM tool schemas?
Most LLM frameworks don't handle oneOf well. Use tagged unions (discriminator field) or separate functions instead. Test if your specific framework supports it.
What if I have 10 optional parameters and the model uses none?
That's fine. The model only includes parameters the user requests or that are contextually relevant. Keep all optional parameters in the schema; let the model decide which to use.
Can I make an array of union types (e.g., list[int | string])?
Yes, but validation is tricky. Use items: {type: ["integer", "string"]} and validate each element at runtime. Or represent it as an array of objects with a type discriminator field.
How do I handle conditional required fields?
Use a discriminator or document the conditions clearly. JSON Schema doesn't enforce conditional requirements, so your validation logic must check: "If field X is Y, then field Z is required."