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.
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
- 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.
Appendix: 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)