Right, let’s talk about the big four: List, Dict, Tuple, and Set. These are the workhorses of nearly every Python program you’ll ever write, and typing them correctly is 80% of the battle. The good news is, it’s also the easiest 80%. The designers got this part mostly right, probably because they were copying from languages that had already figured it out.

The core idea is generics. Don’t let the term scare you. It just means a type that can be parameterized with other types. You’re telling the type checker, “I’m not just using any list; I’m using a list of strings.” It’s the difference between saying “I need a box” and “I need a box specifically for fine china.” The first one could have anything in it—china, shoes, live bees. The second one sets clear expectations and prevents broken plates (or stings).

The List and the One-Track Mind

A List is a one-trick pony, but it’s a very good trick. It holds a sequence of items, and in a well-typed world, we want all those items to be of the same type. You parameterize it by putting the type of its contents in square brackets.

from typing import List

# This is a list. It contains strings. End of story.
def get_capitalized_names(names: List[str]) -> List[str]:
    return [name.title() for name in names]

# MyPy/TypeGuard will happily point out your nonsense here.
random_stuff: List[int] = [1, 2, 3, "four"]  # Error! Found incompatible type "str"

The most common pitfall? The empty list. The type checker isn’t psychic. If you write x = [], its type is effectively List[Any]. You must give it a hint, either in the variable annotation or by putting an item in it.

# Good. We've told the type checker what's coming later.
cached_data: List[float] = []

# Also good. The type of [1.0] is inferred as List[float].
cached_data_better = [1.0]

The Dict, Your Key-Value Workhorse

A Dict requires two type parameters: one for the key and one for the value. It’s Dict[KeyType, ValueType]. The key is almost always a primitive like str, int, or a Tuple of primitives (because it needs to be hashable).

from typing import Dict

# A classic: a dictionary mapping a string key to a list of integer values.
student_grades: Dict[str, List[int]] = {
    "Alice": [85, 90, 95],
    "Bob": [88, 87]
}

def update_grade_book(book: Dict[str, List[int]], student: str, score: int) -> None:
    book.setdefault(student, []).append(score)

A brutally common mistake is trying to index a dict with a key that might not exist. The type checker will assume you’ve lost your mind because, well, you might have.

def get_alice_score(book: Dict[str, int]) -> int:
    return book["Alice"]  # This is fine...

    return book["Charles"]  # ...but this is a type error. "Charles" might not be there!

The correct, defensive way is to use .get() and handle the None case, which forces you to think about the return type (Optional[int]). The type system is literally making you write better, safer code. Neat, huh?

The Tuple: The Fixed-Function Specialist

While a List is homogeneous and variable-length, a Tuple is its precise, slightly obsessive cousin. It is fixed-length and each position can have its own specific type. This is perfect for representing short, structured data, like a point (x, y) or a return value with multiple parts.

You parameterize it by listing the type for each position, in order.

from typing import Tuple

# A 2D point: first item is always an x (float), second is always a y (float).
def shift_point(point: Tuple[float, float], dx: float, dy: float) -> Tuple[float, float]:
    x, y = point
    return (x + dx, y + dy)

# A common return pattern: (status: bool, result: Optional[Value], error: Optional[str])
def risky_operation() -> Tuple[bool, Optional[str], Optional[str]]:
    try:
        result = do_something_risky()
        return True, result, None
    except Exception as e:
        return False, None, str(e)

For a variable-length tuple where all elements are the same type, you can use Tuple[float, ...] (yes, that’s an ellipsis). Use this sparingly; it’s often a sign that a List might be more appropriate.

The Set: For When Uniqueness is the Point

A Set is like a List that refuses to repeat itself. It’s parameterized with a single type, representing the type of all its unique elements.

from typing import Set

def get_unique_user_ids(logs: List[str]) -> Set[int]:
    """Parse logs and return a set of unique user IDs."""
    ids = set()
    for log in logs:
        # ... parsing logic ...
        ids.add(user_id)
    return ids

# This will only ever contain unique integers.
active_users: Set[int] = {1001, 1002, 1005, 1001}  # The last '1001' is silently ignored.

Remember, the contents must be hashable. So a Set[List[str]] is impossible and will make your type checker throw a fit, and rightfully so.

The Old Ways and the New Syntax

Here’s the part where Python’s history shows. You’ll often see this:

from typing import List, Dict

names: List[str]

But in Python 3.9+, the language finally said, “You know what? This is silly.” You can now use the built-in types as generics directly using the standard list, dict, tuple, and set.

# This is the modern way. Do this if you're on 3.9+.
names: list[str]  # Clean, isn't it?
student_grades: dict[str, list[int]]
unique_ids: set[int]
a_point: tuple[float, float]

It means exactly the same thing. The old typing.List is there for backwards compatibility, but for new code, just use the built-ins. It’s one less import and a lot more readable. A rare, unambiguous win from the core devs.