Nested Types and Type Safety
In Lesson 4, you wrote list[str] and dict[str, int] as separate types. Real applications combine them. A SmartNotes app might store multiple notes, each with its own metadata. That means a list of dicts, or a dict of lists. These nested types look complex at first glance, but they follow the same rules you already know.
Emma pulls up a piece of AI-generated code. The type annotation reads list[dict[str, int]].
James squints. "That is a list... of dicts... where the keys are strings and the values are integers?"
"Exactly. Read it from the outside in. The outer type is list. Each element in the list is a dict[str, int]. So it is a list of score dicts."
James nods. "Like a gradebook: each student is a dict, and the whole class is a list of those dicts."
"Perfect analogy."
Reading Nested Types
The rule for reading nested types: start from the outside and work inward.
list[dict[str, int]]
A list of dictionaries. Each dict maps strings to integers:
gradebook: list[dict[str, int]] = [
{"quiz1": 85, "quiz2": 92},
{"quiz1": 78, "quiz2": 88},
{"quiz1": 95, "quiz2": 91},
]
gradebook[0]
gradebook[0]["quiz1"]
Output:
{'quiz1': 85, 'quiz2': 92}
85
First, gradebook[0] gets the first dict. Then ["quiz1"] gets the value from that dict. Two access steps for two nesting levels.
dict[str, list[str]]
A dict where each value is a list of strings:
tag_groups: dict[str, list[str]] = {
"programming": ["python", "javascript", "rust"],
"ai": ["machine-learning", "llm", "agents"],
}
tag_groups["programming"]
tag_groups["programming"][0]
Output:
['python', 'javascript', 'rust']
python
tag_groups["programming"] returns the list. [0] gets the first element of that list.
Nested types are like folders inside folders. list[dict[str, int]] is a folder (list) containing envelopes (dicts), where each envelope has labeled compartments (string keys) holding numbers (int values). You access the number by first opening the right envelope (list index), then finding the right compartment (dict key).
What Pyright Catches
Typed collections let pyright catch errors before you run the code. Here are three common mistakes:
Wrong Element Type
tags: list[str] = ["python", "ai", "beginner"]
tags.append(42) # pyright: Argument of type "int" cannot be assigned to parameter of type "str"
Output (from pyright):
error: Argument of type "int" is not assignable to parameter of type "str"
You promised every element would be a str. Adding an int breaks that promise.
Wrong Key Type
scores: dict[str, int] = {"quiz1": 85}
scores[1] = 90 # pyright: "int" is not assignable to parameter of type "str"
Output (from pyright):
error: Argument of type "int" is not assignable to parameter of type "str"
The key type is str, but you used an int key.
Wrong Value Type
scores: dict[str, int] = {"quiz1": 85}
scores["quiz2"] = "ninety" # pyright: "str" is not assignable to type "int"
Output (from pyright):
error: Type "str" is not assignable to type "int"
The value type is int, but you assigned a str.
Safe Dict Access with .get()
In Lesson 4, you learned that accessing a missing dict key raises KeyError. The .get() method is the safe alternative:
metadata: dict[str, str] = {"author": "James", "topic": "Python"}
metadata["author"]
metadata.get("category", "uncategorized")
metadata.get("author", "unknown")
Output:
James
uncategorized
James
| Pattern | When Key Exists | When Key Missing |
|---|---|---|
d["key"] | Returns value | Raises KeyError |
d.get("key", default) | Returns value | Returns default |
Use d["key"] when you are certain the key exists. Use .get() when the key might be missing. In AI-generated code, you will see .get() frequently because it handles uncertainty gracefully.
PRIMM-AI+ Practice: Reading Nested Types
Predict [AI-FREE]
Look at these nested collection access expressions. Without running anything, predict the exact result of each one. Write your predictions and a confidence score from 1 to 5 before checking.
notes: list[dict[str, str]] = [
{"title": "Python Basics", "author": "James"},
{"title": "AI Primer", "author": "Emma"},
]
result1: dict[str, str] = notes[1]
result2: str = notes[0]["title"]
result3: str = notes[1].get("topic", "general")
Check your predictions
result1: {"title": "AI Primer", "author": "Emma"}. Index 1 is the second dict in the list.
result2: "Python Basics". First, notes[0] gets the first dict. Then ["title"] gets the value for the "title" key.
result3: "general". First, notes[1] gets the second dict. Then .get("topic", "general") looks for the "topic" key, which does not exist, so it returns the default "general".
If you got all three correct, you can read nested types confidently. The key skill is decomposing the access step by step: list index first, then dict key.
Run
Create a file called nested_practice.py with the expressions above. Add print() calls for each result. Run it with uv run python nested_practice.py. Compare the output to your predictions.
Investigate
For result3, explain why .get() returns "general" instead of raising an error. Write one sentence about when to use d["key"] versus d.get("key", default).
Modify
Add a third note to the notes list with a "topic" key: {"title": "Web Dev", "author": "Alice", "topic": "web"}. Predict what notes[2].get("topic", "general") returns now. Run to verify.
Make [Mastery Gate]
Create a list[dict[str, int]] representing quiz results for 3 students. Each dict should have at least 2 quiz scores. Access:
- The second student's dict
- The first student's score on "quiz1"
- Any student's score on "quiz3" using
.get()with a default of 0
Run uv run pyright on your file. Zero errors means you pass.
SmartNotes TDG Challenge
This is your chapter capstone. You will write a function stub, let AI generate the body, and verify the result.
Step 1: Define the Data
tags: list[str] = ["python", "beginner"]
metadata: dict[str, str] = {"author": "James", "topic": "Types"}
notes: list[str] = ["First note body", "Second note body"]
Step 2: Write the Function Stub
Write this stub in a file called smartnotes_tags.py:
def format_tag_list(tags: list[str]) -> str:
"""Join tags into a comma-separated string.
Example: format_tag_list(["python", "ai"]) returns "python, ai"
"""
...
Step 3: Ask AI to Generate the Body
Open Claude Code and paste:
Fill in the body of this function. Use ", ".join(tags) to
join the list into a comma-separated string. Keep it to
one line.
def format_tag_list(tags: list[str]) -> str:
"""Join tags into a comma-separated string."""
...
Step 4: Verify
Add these assertions to your file:
assert format_tag_list(["python", "ai"]) == "python, ai"
assert format_tag_list(["web"]) == "web"
assert format_tag_list([]) == ""
Run uv run python smartnotes_tags.py. If no errors appear, all assertions passed.
Run uv run pyright smartnotes_tags.py. Zero errors means the types are correct.
You just completed a TDG cycle with collection types: you wrote the specification (the function signature and docstring), AI generated the implementation (", ".join(tags)), and you verified the result. This is the same pattern you will use for every function in SmartNotes.
Ch 48 Syntax Card: Strings and Collections
# Ch 48 Syntax Card: Strings and Collections
f"{name} has {count} notes"
f"Time: {minutes:.1f} min"
s[0] # first character
s[-1] # last character
s[1:4] # characters 1,2,3
len(s) # length
s.strip() # remove whitespace
s.upper() # "HELLO"
s.split() # ["hello", "world"]
s.replace("a", "b")
s.find("x") # index or -1
tags: list[str] = ["a", "b"]
scores: dict[str, int] = {"q1": 85}
point: tuple[float, float] = (3.14, 2.71)
unique: set[str] = {"a", "b"}
tags[0] # "a"
scores["q1"] # 85
scores.get("missing", 0) # 0
"a" in tags # True
len(tags) # 2
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Explain a Nested Type
Explain what this Python type annotation means in plain
English: list[dict[str, list[str]]]. Give me one concrete
example of data that matches this type.
Read AI's response. Did it correctly describe it as "a list of dictionaries, where each dict maps strings to lists of strings"? Does the concrete example match that structure? Compare the explanation to what you learned about reading nested types from the outside in.
What you're learning: You are practicing the skill of reading complex type annotations by having AI explain them. In real projects, you will encounter nested types in AI-generated code and need to understand them quickly.
Prompt 2: Generate a Collection Function
Write a Python function called count_tags that takes a
list[dict[str, list[str]]] representing notes (each note
is a dict with a "tags" key mapping to a list of tag
strings) and returns a dict[str, int] counting how many
times each tag appears across all notes. Use type
annotations on all parameters and the return type.
Read AI's output. Check: does the function signature match the types you expect? Are the parameter and return annotations correct? You are not expected to understand the body (it may use a loop, which is Phase 3 material), but you should be able to read the signature and verify it matches the specification.
What you're learning: You are reading and evaluating function signatures that use nested collection types. The body is AI's job; the signature is yours.
Key Takeaways
-
Read nested types from the outside in.
list[dict[str, int]]is a list of dicts. Each dict maps strings to integers. Two nesting levels means two access steps. -
Pyright catches collection type errors. Adding an
intto alist[str], using the wrong key type, or assigning the wrong value type all produce clear error messages. -
.get()prevents KeyError. Used.get("key", default)when a key might not exist. Used["key"]when you are certain it does. -
Function stubs with collection types are powerful specifications.
format_tag_list(tags: list[str]) -> strtells AI exactly what to build. The types are the contract.
Looking Ahead
You now know strings and collections. In Chapter 49, you combine everything into function signatures with multiple parameters, default values, and keyword arguments. The types you learned here become the vocabulary for specifying richer functions: def search_notes(notes: list[str], keyword: str) -> list[str]. That signature IS the specification. AI reads it and knows exactly what to build.