rohitg00/ai-engineering-from-scratch · error · ContractError
expected object
Error message
expected object
What it means
ContractError '$: expected object' is raised by parse_and_validate when the parsed JSON root is not a dict but the schema declares type 'object'. The lesson's validate() checks isinstance(value, dict) at the root and returns the single issue; parse_and_validate wraps collected issues into one ContractError (a ValueError subclass carrying .issues). It enforces that model output strictly matches the JSON schema contract instead of silently coercing.
Source
Thrown at certifications/claude/lessons/09-structured-output-and-defensive-parsing/code/main.py:47
"additionalProperties": False,
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "account", "other"]},
"priority": {"type": "integer", "minimum": 1, "maximum": 5},
"summary": {"type": "string", "minLength": 1, "maxLength": 240},
"needs_human": {"type": "boolean"},
},
}
def parse_and_validate(raw: str, schema: dict[str, Any]) -> Any:
"""Accept exactly one JSON value, then validate the supported schema subset."""
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise ContractError([ValidationIssue("$", f"invalid JSON at character {exc.pos}")]) from exc
issues = validate(value, schema)
if issues:
raise ContractError(issues)
return value
def validate(value: Any, schema: dict[str, Any], path: str = "$") -> list[ValidationIssue]:
issues: list[ValidationIssue] = []
expected = schema.get("type")
if expected == "object":
if not isinstance(value, dict):
return [ValidationIssue(path, "expected object")]
properties = schema.get("properties", {})
for name in schema.get("required", []):
if name not in value:
issues.append(ValidationIssue(f"{path}.{name}", "required field is missing"))
if schema.get("additionalProperties") is False:
for name in value:
if name not in properties:
issues.append(ValidationIssue(f"{path}.{name}", "unexpected field"))
for name, child in properties.items():View on GitHub (pinned to 39ea8a1c6d)
Solutions
- Inspect exc.issues[0].path (it will be '$') to confirm the root itself failed, then fix the payload or prompt so the output is a JSON object literal '{...}'.
- If you control generation, instruct 'respond with only a JSON object' and run through BoundedExtractor so the repair loop retries with feedback.
- If a non-object is legitimately possible, branch on the json.loads result type before validating, or relax the schema type.
- In tests, make fixtures start with '{' and end with '}' and assert with pytest.raises(ContractError).
Example fix
# before
value = parse_and_validate('"billing question"', TRIAGE_SCHEMA)
# ContractError: $: expected object
# after
value = parse_and_validate('{"category": "billing", "priority": 2, "summary": "refund", "needs_human": false}', TRIAGE_SCHEMA) Defensive patterns
Strategy: try-catch
Validate before calling
import json
def is_json_object(raw: str) -> bool:
try:
return isinstance(json.loads(raw), dict)
except json.JSONDecodeError:
return False Type guard
from typing import Any
def is_contract_object(value: Any) -> bool:
return isinstance(value, dict) Try / catch
try:
data = parse_and_validate(raw, TRIAGE_SCHEMA)
except ContractError as exc:
for issue in exc.issues:
print(f"{issue.path}: {issue.message}") # '$: expected object' means root is not a dict
data = None Prevention
- Prompt the model to 'reply with only a JSON object' and show an example with braces.
- Pre-check json.loads(raw) is a dict before full validation.
- Echo the '$' issue back to the model as repair feedback in retry loops.
When it happens
Trigger: Calling parse_and_validate(raw, schema) where schema has "type": "object" and raw is valid JSON but a non-object: '"just a string"', '[1,2,3]', '42', 'true', 'null'. Also hit in BoundedExtractor.extract when the generate stub returns a bare JSON scalar, and by tests like test_valid_object_is_returned feeding scalar payloads.
Common situations: LLM returns a bare scalar or array instead of an object (e.g. answers with just a summary string); markdown-fenced output parsed to something unexpected; prompt drift where the model stops emitting the object; hand-written test fixtures that omit the outer braces.
Related errors
- invalid JSON at character {exc.pos}
- review_goal.content must be an object
- -32600
- -32603
- Budget.step requires non-negative tokens and dollars
AI-assisted analysis of rohitg00/ai-engineering-from-scratch@39ea8a1c6d (2026-08-26).
Data as JSON: /api/errors/5d89fde9864299d2.
Report an issue: GitHub.