shareAI-lab/learn-claude-code · error · ValueError

todos must be a list

Error message

todos must be a list

What it means

Raised by TodoManager.update in s05_todo_write/code.py when, after optional string parsing, the todos value is not a Python list. Unlike error 30 this fires when the value parsed successfully (or was passed natively) but has the wrong top-level type — typically a dict (single object) or a string like "pending" that literal_eval accepts as a non-list value.

Source

Thrown at s05_todo_write/code.py:125

# -- New in s05: structured state the model updates --

class TodoManager:
    def __init__(self):
        self.items: list[dict] = []

    def update(self, todos: list | str) -> str:
        if isinstance(todos, str):
            try:
                todos = json.loads(todos)
            except json.JSONDecodeError:
                try:
                    todos = ast.literal_eval(todos)
                except (SyntaxError, ValueError) as e:
                    raise ValueError("todos must be a list or JSON array string") from e

        if not isinstance(todos, list):
            raise ValueError("todos must be a list")
        if len(todos) > 20:
            raise ValueError("Max 20 todos allowed")

        validated = []
        in_progress_count = 0
        for index, todo in enumerate(todos):
            if not isinstance(todo, dict):
                raise ValueError(f"todos[{index}] must be an object")

            content = str(todo.get("content", "")).strip()
            status = str(todo.get("status", "pending")).lower()
            if not content:
                raise ValueError(f"todos[{index}] requires content")
            if status not in ("pending", "in_progress", "completed"):
                raise ValueError(f"todos[{index}] has invalid status '{status}'")
            if status == "in_progress":
                in_progress_count += 1
            validated.append({"content": content, "status": status})

View on GitHub (pinned to 985456f4ad)

Solutions

  1. Wrap single items in a list: update([todo]) not update(todo)
  2. Extract the array field from the arguments object before calling: args['todos'], not args
  3. Check isinstance(todos, list) client-side and re-shape before the call

Example fix

// before
TODOS.update({"content": "x", "status": "pending"})
// after
TODOS.update([{"content": "x", "status": "pending"}])
Defensive patterns

Strategy: type-guard

Validate before calling

if isinstance(todos, dict):
    todos = [todos]            # single object -> wrap
elif isinstance(todos, dict) and 'todos' in todos:
    todos = todos['todos']     # arguments wrapper -> extract
if not isinstance(todos, list):
    raise TypeError('expected a list of todos')
TODOS.update(todos)

Type guard

def is_todo_list(v: object) -> bool:
    return isinstance(v, list) and all(isinstance(x, dict) for x in v)

Try / catch

try:
    TODOS.update(todos)
except ValueError as e:
    if 'must be a list' in str(e) and isinstance(todos, dict):
        TODOS.update([todos])
    else:
        raise

Prevention

When it happens

Trigger: Passing a single todo object {"content": ...} instead of a list wrapping it; passing a JSON object {"todos": [...]} (a dict); passing "'pending'" which literal_eval parses to a str; passing an int or None.

Common situations: LLMs omitting the outer array for single-item updates; nested schemas where the caller passes the whole arguments object rather than the todos field; string inputs that are valid Python literals of the wrong type.

Related errors


AI-assisted analysis of shareAI-lab/learn-claude-code@985456f4ad (2026-08-14). Data as JSON: /api/errors/a923ed75213dfb18. Report an issue: GitHub.