Skip to main content
Lesson 12 of 16 · Prompt Engineering for Developers

Complex patterns: nested lists, enums, unions

Lesson 12 of 16 Updated July 28, 2026 Developer
On this page
  1. Complex patterns: nested lists, enums, unions
  2. Nested lists
  3. Enums to constrain values
  4. Simulating type unions
  5. Recursive objects
  6. Pattern: multi-entity extraction
  7. Best practices for complex schemas
  8. The case that tests everything: the org chart
  9. Key takeaways

Complex patterns: nested lists, enums, unions

Simple extraction cases (a flat object with text fields) are easy to handle. The real challenges arrive with complex structures: nested object lists, conditional types, and polymorphic fields. This lesson shows you how to design robust schemas for these advanced cases.

Nested lists

The most common pattern in production involves extracting a list of objects where each object contains its own sub-lists. The schema below describes a training program with three levels: the program object with its title and duration, an array of modules each with a title and text objectives, and finally the lessons for each module, described by a title, a duration in minutes, and a type constrained by an enum.

What the model must succeed at here goes beyond reading: it needs to attach each lesson to the correct module, meaning it must understand a hierarchy sometimes conveyed only by heading levels. Hence the very concrete fields at the third level—a duration in minutes rather than a generic “duration” expressed sometimes in hours, sometimes in sessions. The more precise the leaf nodes of the tree, the less the model hesitates.

import json
from openai import OpenAI

client = OpenAI()

# Schema: extract a training program with modules and lessons
formation_schema = {
    "type": "object",
    "properties": {
        "titre": {"type": "string"},
        "duree_totale_heures": {"type": "number"},
        "modules": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "titre_module": {"type": "string"},
                    "objectifs": {
                        "type": "array",
                        "items": {"type": "string"}
                    },
                    "lecons": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "titre_lecon": {"type": "string"},
                                "duree_minutes": {"type": "integer"},
                                "type": {
                                    "type": "string",
                                    "enum": ["theorie", "pratique",
                                             "évaluation"]
                                }
                            },
                            "required": ["titre_lecon",
                                         "duree_minutes", "type"],
                            "additionalProperties": False
                        }
                    }
                },
                "required": ["titre_module", "objectifs", "lecons"],
                "additionalProperties": False
            }
        }
    },
    "required": ["titre", "duree_totale_heures", "modules"],
    "additionalProperties": False
}

Enums to constrain values

Enums are the most reliable way to limit the possible values of a field. Structured Output applies them at the generation level: unlike an instruction written in the prompt, they cannot be ignored on a day of statistical fatigue. This makes them valuable when the extracted value feeds a switch statement, an indexed column, or a routing rule—three places where an unexpected value doesn’t cause a hard error, but rather silence.

The classification schema that follows sorts incoming requests along four axes: categorie_principale locates the relevant department, sous_categorie refines it within that department, priorite serves for queue sorting, and action_requise determines what to do with the ticket. One design point deserves attention: the twelve values of sous_categorie are flattened rather than attached to their parent category, because strict mode does not offer conditional dependency between two enums. Nothing therefore prevents the combination “juridique” and “recrutement”, and this consistency must be verified after reception.

# Multi-level classification system
classification_schema = {
    "type": "object",
    "properties": {
        "categorie_principale": {
            "type": "string",
            "enum": ["technique", "commercial", "juridique",
                     "rh", "finance"]
        },
        "sous_categorie": {
            "type": "string",
            "enum": [
                "bug", "feature", "performance",
                "devis", "facturation", "contrat",
                "recrutement", "conge", "formation",
                "budget", "audit", "conformite"
            ]
        },
        "priorite": {
            "type": "string",
            "enum": ["critique", "haute", "moyenne", "basse"]
        },
        "action_requise": {
            "type": "string",
            "enum": ["repondre", "escalader", "archiver",
                     "planifier", "deleguer"]
        }
    },
    "required": ["categorie_principale", "sous_categorie",
                  "priorite", "action_requise"],
    "additionalProperties": False
}

Simulating type unions

JSON Schema supports anyOf for conditional types, but OpenAI’s strict Structured Output has limitations. The recommended pattern is to use a discriminator field: you define a single object, broad enough to cover all cases, and a type field indicates which one you are describing.

# Pattern: an event can be a meeting, a task, or a reminder
event_schema = {
    "type": "object",
    "properties": {
        "type": {
            "type": "string",
            "enum": ["reunion", "tache", "rappel"]
        },
        "titre": {"type": "string"},
        "date": {"type": "string"},
        "heure_debut": {"type": ["string", "null"]},
        "heure_fin": {"type": ["string", "null"]},
        "participants": {
            "type": "array",
            "items": {"type": "string"}
        },
        "lieu": {"type": ["string", "null"]},
        "priorite": {
            "type": ["string", "null"],
            "enum": ["haute", "moyenne", "basse", None]
        },
        "description": {"type": "string"}
    },
    "required": ["type", "titre", "date", "heure_debut",
                  "heure_fin", "participants", "lieu",
                  "priorite", "description"],
    "additionalProperties": False
}

The trick is to make any field specific to a single type nullable, using the notation "type": ["string", "null"]. For a meeting, participants and lieu are filled; for a reminder, they equal null. Your code then reads the discriminator first and immediately knows which keys make sense—exactly the reading you would do of a tagged union in a typed language. The price to pay, a more verbose schema and nulls to handle, remains preferable to three separate calls.

Recursive objects

For tree structures—menus, org charts, discussion threads—use $defs for recursive references. The principle is to name the repeating shape once, then reference it from within itself. Here, a comment contains an array of replies where each element is in turn a comment: the depth is not fixed at write time, it follows that of the analyzed document. This is the only reasonable way to process a discussion thread without knowing in advance whether it has two levels of replies or seven.

# Structure of a comment with nested replies
comment_schema = {
    "type": "object",
    "properties": {
        "commentaires": {
            "type": "array",
            "items": {"$ref": "#/$defs/comment"}
        }
    },
    "required": ["commentaires"],
    "additionalProperties": False,
    "$defs": {
        "comment": {
            "type": "object",
            "properties": {
                "auteur": {"type": "string"},
                "contenu": {"type": "string"},
                "sentiment": {
                    "type": "string",
                    "enum": ["positif", "negatif", "neutre"]
                },
                "reponses": {
                    "type": "array",
                    "items": {"$ref": "#/$defs/comment"}
                }
            },
            "required": ["auteur", "contenu", "sentiment",
                          "reponses"],
            "additionalProperties": False
        }
    }
}

Pattern: multi-entity extraction

When a text contains multiple types of entities, the temptation is to run one extraction per family. The multi-entity schema does better: it requests people, organizations, dates, and amounts in a single pass. The benefit is not just saving calls; it’s that the model sees the text once and can relate what it finds there—attaching a person to their organization, associating an amount with the context that gives it meaning.

The duplication on dates, date_brute and date_iso, deserves to be kept as is in your own schemas: it preserves the original phrasing for auditing while providing a normalized value. The contexte field, present on dates and amounts, plays the same role: without it, no one will know if 12,000 euros was a quote, a penalty, or an annual budget.

multi_entity_schema = {
    "type": "object",
    "properties": {
        "personnes": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "nom": {"type": "string"},
                    "role": {"type": "string"},
                    "organisation": {"type": ["string", "null"]}
                },
                "required": ["nom", "role", "organisation"],
                "additionalProperties": False
            }
        },
        "organisations": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "nom": {"type": "string"},
                    "type": {
                        "type": "string",
                        "enum": ["entreprise", "association",
                                 "administration", "autre"]
                    },
                    "secteur": {"type": ["string", "null"]}
                },
                "required": ["nom", "type", "secteur"],
                "additionalProperties": False
            }
        },
        "dates": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "date_brute": {"type": "string"},
                    "date_iso": {"type": "string"},
                    "contexte": {"type": "string"}
                },
                "required": ["date_brute", "date_iso", "contexte"],
                "additionalProperties": False
            }
        },
        "montants": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "valeur": {"type": "number"},
                    "devise": {"type": "string"},
                    "contexte": {"type": "string"}
                },
                "required": ["valeur", "devise", "contexte"],
                "additionalProperties": False
            }
        }
    },
    "required": ["personnes", "organisations", "dates", "montants"],
    "additionalProperties": False
}

Best practices for complex schemas

Four habits avoid most disappointments. Start simple: validate a flat object on your real data before adding any nesting, otherwise you won’t know if a failure comes from the schema or the difficulty of the task. Next, give up on the optional field, which strict mode forbids, and make it nullable instead. Also respect an empirical limit—beyond three or four levels of nesting quality drops, and it’s better to split the extraction into two passes: first the list of modules, then one call per module to detail its lessons. Finally, keep in mind the division of labor: the schema and its enums constrain the structure, the system prompt guides the content. If the model sorts correctly but extracts the wrong information, it’s the prompt you need to tweak.

The case that tests everything: the org chart

Tackle the recursive case, the most educational part of the lesson. Define a schema to extract a company org chart, where each employee can have subordinates who are themselves employees. Test it on a text describing an organization of fifteen to twenty people, written in prose and not as a list: this is where the model must infer reporting lines from phrases like “reports to” or “within the team of”.

Verify that hierarchical relationships are correctly nested, watching for people mentioned far from their manager—this is the usual breaking point. Finally, add enums on roles (executive, management, operational) and observe whether the constraint improves or degrades the reporting line: forcing a categorization can help the model or push it toward arbitrary choices on hybrid profiles.

Key takeaways

  • Nested lists are the most common pattern in production
  • Enums are applied at the generation level—exploit them
  • Simulate unions with a discriminator field and nullable fields
  • $defs enables recursive structures (comments, trees)
  • Limit nesting depth to 3-4 levels