مرکزی مواد پر جائیں

Nested Types and Type Safety

In Lesson 4, you learned four collection types: list[str], dict[str, int], tuple, and set. Each one held simple values like strings or integers inside it.

But what if you need a list where each item is not a simple string, but an entire dictionary? For example, a class of students where each student has quiz scores stored in a dict. You would need a list of dicts: a collection inside a collection.

This is called nesting: putting one collection type inside another. The type annotation looks like list[dict[str, int]]. It seems complex, but if you can read list[str] ("a list of strings"), you can read list[dict[str, int]] ("a list of dicts") the same way. The thing inside the brackets just got bigger.

This lesson teaches you how to read these nested types, how pyright catches mistakes in your collections, and ends with your first TDG cycle using collection types.


Reading Nested Types

The rule for reading nested types: start from the outside and work inward.

Here is how to read list[dict[str, int]] step by step:

list [ dict [ str, int ] ]
│ │ │ │
│ │ │ └── values are integers
│ │ └── keys are strings
│ └── each item is a dict
└── the outer container is a list

Peel one layer at a time: the outermost type tells you the container, and everything inside the brackets tells you what the container holds.

list[dict[str, int]] — A list of dictionaries

Read it in pieces:

  • Outermost: list[...] — it is a list
  • Inside the list: dict[str, int] — each item is a dict with string keys and integer values

A concrete example: a gradebook where each student's scores are stored as a dict.

gradebook: list[dict[str, int]] = [
{"quiz1": 85, "quiz2": 92},
{"quiz1": 78, "quiz2": 88},
{"quiz1": 95, "quiz2": 91},
]

To get a single number out, you need two steps:

# Step 1: get the first dict from the list (by index)
student: dict[str, int] = gradebook[0]
print(student)

# Step 2: get a value from that dict (by key)
score: int = student["quiz1"]
print(score)

Output:

{'quiz1': 85, 'quiz2': 92}
85

You can combine both steps into one line: gradebook[0]["quiz1"]. Two brackets, two steps:

  • [0] is a list index (picks which dict from the list)
  • ["quiz1"] is a dict key (picks which value from that dict)

Each bracket matches its collection type: numbers for lists, strings for dicts.

dict[str, list[str]] — A dict where each value is a list

Read it in pieces:

  • Outermost: dict[str, ...] — it is a dict with string keys
  • The values: list[str] — each value is a list of strings

A concrete example: tag categories, where each category name maps to a list of tags.

tag_groups: dict[str, list[str]] = {
"programming": ["python", "javascript", "rust"],
"ai": ["machine-learning", "llm", "agents"],
}

Again, two steps to reach a single string:

# Step 1: get the list from the dict (by key)
prog_tags: list[str] = tag_groups["programming"]
print(prog_tags)

# Step 2: get a single item from that list (by index)
first_tag: str = prog_tags[0]
print(first_tag)

Output:

['python', 'javascript', 'rust']
python

Or in one line: tag_groups["programming"][0]. Two brackets again:

  • ["programming"] is a dict key (picks which list from the dict)
  • [0] is a list index (picks which item from that list)

What Pyright Catches

When you write list[str] or dict[str, int], you are making a promise about what types go inside. Pyright checks that you keep that promise. Here are three common mistakes it catches:

Wrong element type in a list

tags: list[str] = ["python", "ai", 42]

Pyright error:

error: Type "int" is not assignable to type "str"

You promised every element would be a str. The 42 is an int, which breaks that promise.

Wrong key type in a dict

scores: dict[str, int] = {"quiz1": 85}
scores[1] = 90

Pyright error:

error: Argument of type "int" is not assignable to parameter of type "str"

The key type is str, but you used 1 (an int) as a key.

Wrong value type in a dict

scores: dict[str, int] = {"quiz1": 85}
scores["quiz2"] = "ninety"

Pyright error:

error: Type "str" is not assignable to type "int"

The value type is int, but you assigned "ninety" (a str).

In each case, pyright catches the mistake before you run the code. This is the payoff of writing type annotations: errors show up in your editor, not at runtime.


Safe Access with .get() in Nested Types

You learned .get() in Lesson 4 for simple dicts. It becomes even more important with nested types, because a missing key in the middle of a chain can crash your entire expression.

notes: list[dict[str, str]] = [
{"title": "Python Basics", "author": "James"},
{"title": "AI Primer"}, # this dict has no "author" key
]

Unsafe access on the second note would crash:

# notes[1]["author"]  # KeyError: 'author'

Safe access with .get():

author: str = notes[1].get("author", "unknown")
print(author)

Output:

unknown

The rule: use d["key"] when you are certain the key exists. Use .get("key", default) when it might be missing. In nested structures, .get() prevents a single missing key from crashing everything.


PRIMM-AI+ Practice: Reading Nested Types

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode before predicting.

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

Press Shift+Tab to exit Plan Mode.

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

If you want to go deeper, run /investigate @nested_practice.py in Claude Code and ask when .get() is safer than bracket access in nested structures.

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:

  1. The second student's dict
  2. The first student's score on "quiz1"
  3. 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. If you have the starter kit installed, you can run /tdg in Claude Code to guide you through the cycle.

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:

Implement this function. The stub and docstring describe
what it should do. Keep it simple.

def format_tag_list(tags: list[str]) -> str:
"""Join tags into a comma-separated string.

Example: format_tag_list(["python", "ai"]) returns "python, ai"
"""
...

You are giving AI the specification (the signature, docstring, and example). AI decides how to implement it. Your job is to verify the result in Step 4.

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

Opening Claude Code

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.


James looks at his format_tag_list function. "I wrote the signature and the docstring. AI wrote the body. And the assertions prove it works."

"That is the TDG cycle," Emma says. "Specify with types, generate with AI, verify with tests. You just did it with collection types."

"The nested types were the hard part," James admits. "list[dict[str, int]] looked scary at first. But once I read it from the outside in, it was just a list of dicts."

Emma smiles. "You now have the full type vocabulary: primitives from Chapter 47, strings and collections from this chapter. In Chapter 49, you put it all together into function signatures with multiple parameters, default values, and keyword arguments. A signature like def search_notes(notes: list[str], keyword: str) -> list[str] becomes your specification. The types tell AI exactly what to build."