šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Type Hints

Master the typing system that lets mypy and pyright catch bugs before you run a single test — from basic annotations to generics and Protocols.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Does calling greet(42) crash at runtime if greet is annotated def greet(name: str) -> str?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

Python's type hints, introduced in PEP 484, turned an entirely dynamic language into one with an optional, gradual static-typing layer. This lesson covers what type hints actually are, how the checker reasons about them, and how professional codebases use them to catch bugs at write-time instead of runtime.

1What Type Hints Actually Are (and Are Not)

PEP 484 introduced type hints as an entirely optional annotation syntax layered on top of Python's existing dynamic typing — nothing about the runtime execution model changed. When you write def greet(name: str) -> str:, the interpreter stores str in the function's __annotations__ dict and moves on; it does not insert any check that name is actually a string when greet is called.

The actual enforcement comes from a separate static type checker — mypy, pyright, or pyre — run as a standalone tool, typically in your editor in real time and again in CI. These tools perform static analysis: they trace how values flow through your code without executing it, and flag any place where an annotated type doesn't match how a value is actually used.

This separation is deliberate and is what makes Python's typing 'gradual': you can annotate one module fully, leave another completely untyped, and both run identically. Teams adopt typing incrementally, starting with public APIs and expanding inward, rather than needing an all-or-nothing rewrite.

āœ•
—
+
def greet(name: str) -> str:
    return f"Hello, {name}!"

greet(42)  # mypy error: Argument 1 has incompatible type "int"; expected "str"
# runtime: still just prints "Hello, 42!" — Python itself never checks
localhost:3000
mypy Output
error: Argument 1 to "greet" has incompatible type "int"; expected "str"

2Generic Containers and Optional Values

Bare list or dict annotations tell a type checker almost nothing useful — list[int] versus list[str] is the difference between code that's actually verified and code that merely looks annotated. Since Python 3.9, you can use the built-in generics directly (list[int], dict[str, float]) without importing List/Dict from typing, which was required pre-3.9.

Optional[X], or the equivalent and now-preferred X | None syntax (3.10+), documents that a value can legitimately be absent and forces callers to handle it. This is where type checkers add real value beyond documentation: once you write if user is not None:, mypy performs type narrowing — inside that branch, it treats user as dict, not dict | None, and would flag any attempt to use .get() on it outside the guard as potentially operating on None.

The discipline this creates is significant: a function that returns dict | None makes it impossible to 'forget' the not-found case without the type checker complaining, closing off an entire class of AttributeError: 'NoneType' object has no attribute bugs that would otherwise only surface at runtime, often days later in production.

āœ•
—
+
def total_score(scores: list[int]) -> int:
    return sum(scores)

total_score([90, 85, 77])   # OK
total_score(["90", "85"])  # mypy error
localhost:3000
Type Narrowing
Inside `if user is not None:`
mypy treats user as dict, not dict | None

3Protocols: Typing Behavior, Not Ancestry

Python has always favored duck typing — 'if it walks like a duck and quacks like a duck, treat it as a duck' — and for years that idiom had no clean way to express itself in the type system, forcing a choice between inheriting from an abstract base class or giving up on static checking entirely. typing.Protocol, from PEP 544, solves this with structural typing: a class satisfies Sized simply by having a __len__ method, with zero explicit relationship to the Protocol class itself.

This matters enormously for library code. A function that accepts a Sized parameter works with list, str, dict, a custom class, or literally any third-party type that happens to implement __len__ — none of which need to know your Protocol exists, let alone inherit from it. Nominal typing (explicit inheritance, as with ABCs) would force every one of those types to be modified or wrapped just to satisfy the signature.

Protocols are how the standard library itself is typed in many places — Iterable, Iterator, Sized, and Hashable in typing are all Protocols under the hood — and they're the idiomatic tool whenever you're typing 'accepts anything with this interface' rather than 'accepts instances of this specific class family'.

āœ•
—
+
def find_user(user_id: int) -> dict | None:
    return database.get(user_id)

user = find_user(7)
if user is not None:
    print(user["name"])  # mypy is happy: None was narrowed out
localhost:3000
Structural Match
describe([1, 2, 3])
list satisfies Sized — no inheritance needed

4Step-by-Step Breakdown

Every production Python codebase you'll work on professionally uses type hints. Let's learn to read and write them fluently.

A type hint is just an annotation. Python doesn't enforce it at runtime — a static checker like mypy reads it and flags mismatches before you ship.

Checkpoint: Does calling greet(42) crash at runtime if greet is annotated def greet(name: str) -> str?

  • →No — Python does not enforce type hints at runtime
  • →Yes — Python raises a TypeError immediately

Container types need their contents typed too. list[int] means a list where every element is an int — not just 'a list'.

Optional[X] (or X | None) says a value might be None. It forces you to handle the None case explicitly before using the value.

For behavior-based typing (duck typing), use Protocol — it describes 'anything with this method', without requiring inheritance.

Checkpoint: What does typing.Protocol enable that a normal abstract base class does not?

  • →Structural typing — matching by shape, with no explicit inheritance required
  • →Faster attribute lookups at runtime

Type hints unlock a whole family of related tools — next up, Union Types, for expressing 'this could be one of several types.'

Narrow a Real Optional Type. Finish greet(): checking for None first is exactly how you narrow an Optional type.

Level Up šŸš€

Advanced cheat sheets, SEO tricks, and interview prep for this topic.

Browser Support

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

Fully supported (via server-side Python execution).

Best Practices

Type public function signatures first, expand inward gradually

You get the highest signal-to-effort ratio by typing module boundaries and public APIs first — that's where mismatches cause the most damage and where callers benefit most from editor autocompletion.

Run mypy (or pyright) in CI, not just locally

Type hints without an enforced checker in CI silently rot — someone eventually breaks a signature and nobody notices until the next person manually opens their editor. `mypy --strict` as a required CI check keeps annotations honest.

Frequent Bugs

THE BUG

Believing a function is 'safe' from type errors simply because it has annotations, without ever actually running a type checker against it.

THE FIX

Type hints are inert without a checker. Install and run mypy or pyright — in your editor and in CI — for annotations to provide any real protection.

Real-World Examples

A Typed Repository Interface with Protocol

A service layer needs to accept any data-access object that can fetch a user by ID, without coupling to a specific database implementation (Postgres, in-memory test double, etc.).

from typing import Protocol

class UserRepository(Protocol):
    def get_user(self, user_id: int) -> dict | None: ...

def get_display_name(repo: UserRepository, user_id: int) -> str:
    user = repo.get_user(user_id)
    return user["name"] if user else "Unknown User"

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Using the pre-3.9 typing.List, typing.Dict imports in new code when the project targets 3.9+, adding unnecessary import noise.

# Outdated (still works, but unnecessary on 3.9+) from typing import List, Dict def process(items: List[int]) -> Dict[str, int]: ... # Modern def process(items: list[int]) -> dict[str, int]: ...

The Solution //

On Python 3.9+, use the built-in generics directly: list[int], dict[str, int], tuple[int, ...] — no import from typing required for these.

Lesson Glossary

[01]Type hint

An optional annotation on a variable, parameter, or return value indicating its expected type, stored in __annotations__ but not enforced by the interpreter.

Code Preview
// Type hint context

[02]Gradual typing

A typing philosophy where annotated and unannotated code coexist and interoperate freely, allowing incremental adoption across a codebase.

Code Preview
// Gradual typing context

[03]Type narrowing

The process by which a static type checker refines a value's known type within a specific code branch, e.g. treating X | None as X after an is not None check.

Code Preview
// Type narrowing context

[04]Protocol

A typing construct (PEP 544) that defines structural typing — any object with matching methods/attributes satisfies it, without needing to inherit from it.

Code Preview
// Protocol context

Continue Learning