DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

Type Hints in Python (typing module)

The earlier docstrings and annotations article covered basic hints like param: int. This article goes considerably deeper into the python typing module itself — unions, generics, structural typing with Protocol, and shaped dictionaries with TypedDict — the territory where most "type hints 101" articles stop short.

From Basic Annotations to the typing Module's Real Power

A quick recap

Simple annotations — param: int, -> str — cover the basic cases well, as covered in the earlier article. The typing module unlocks considerably more precise expressiveness: unions of several possible types, generics that preserve type relationships across a function's inputs and outputs, structural interfaces, and precisely shaped dictionaries.

The evolution worth knowing

A handful of PEPs shaped typing into what it is today: PEP 484 introduced type hints themselves. PEP 526 added syntax for variable annotations outside function signatures. PEP 585 (Python 3.9) brought built-in generics — list[int] directly, rather than requiring List[int] imported from typing. PEP 604 (Python 3.10) introduced the | union shorthand. Through all of this evolution, one thing hasn't changed: these remain just annotations — Python itself never enforces any of them at runtime, exactly as covered in the earlier article. Everything in this article is about writing more precise, more useful hints for readers and static type checkers — not about adding runtime validation, which Section 5 addresses separately.

What this article covers

Modern union and optional syntax, generics with TypeVar, Protocol for structural typing, TypedDict for precisely shaped dictionaries, and finally, running Mypy for real and where runtime validation (Pydantic) actually fits in.

Modern Union and Optional Syntax

Optional[X] vs. X | None
from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    ...
# Modern equivalent (Python 3.10+)
def find_user(user_id: int) -> dict | None:
    ...

Both mean exactly the same thing: this function returns either a dict, or None. The | syntax is now the recommended default, unless your project specifically needs to support Python versions older than 3.10, in which case Optional[X] (or a from __future__ import annotations workaround) remains necessary.

Union[X, Y] vs. X | Y
from typing import Union

def process(value: Union[int, str]) -> str:
    ...
# Modern equivalent
def process(value: int | str) -> str:
    ...

Same relationship: X | Y is the modern, preferred shorthand for "this could be either type," replacing Union[X, Y] from typing for anything targeting Python 3.10 and later.

Built-in generics no longer require imports

Since Python 3.9, list[int], dict[str, int], and similar work directly as generic type hints, without importing List/Dict from typing at all:

# Modern (3.9+)
def get_names() -> list[str]:
    ...

def get_scores() -> dict[str, int]:
    ...
# Older, still-valid equivalent, needed for pre-3.9 support
from typing import List, Dict

def get_names() -> List[str]:
    ...
A practical example combining both
def normalize_id(user_id: int | str) -> str:
    """Accepts either an int or str ID and normalizes it to a string."""
    return str(user_id)

print(normalize_id(42))       # "42"
print(normalize_id("42"))     # "42"

The int | str annotation communicates precisely, at a glance, that this function genuinely accepts either type — a static type checker (covered in Section 5) would flag a call like normalize_id([1, 2]) as an error, even though Python itself would still run it without complaint.

Generics with TypeVar and Generic

The problem generics solve

Consider a general-purpose function meant to work with any list:

def first(items: list) -> object:
    return items[0]

result = first([1, 2, 3])
# result is typed as 'object' — the specific fact that it's actually an int is lost!

Annotating the return type as plain object is technically valid, but it throws away genuinely useful information — a static type checker has no way of knowing that calling first() on a list[int] actually returns an int, specifically, rather than some arbitrary, unknown object. Generics solve exactly this: writing a function or class once, but preserving the specific, consistent type relationship between its inputs and outputs.

The basic pattern with TypeVar
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

result = first([1, 2, 3])
# a type checker now correctly infers result is an int, not just 'object'

result2 = first(["a", "b", "c"])
# and here, correctly infers result2 is a str

T is a placeholder — a type variable — that gets "filled in" consistently for a given call. Whatever specific type T resolves to for items (here, int or str), the return type is understood to be that exact same type, preserving the connection between input and output that plain object would have lost entirely.

A generic class with Generic[T]
from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
# a type checker knows int_stack.pop() returns an int, specifically

Stack(Generic[T]) declares that Stack is generic over some type T — and Stack[int] specifically instantiates that generic with T bound to int for this particular instance, letting a type checker correctly track that every push() and pop() on int_stack deals specifically with int values, not some generic, unspecified type.

A brief note on variance

There's a more advanced nuance worth knowing exists, without diving deep into it here: variance — specifically covariance and contravariance — governs how generic types relate to each other when subtyping is involved (for instance, whether a Stack[Dog] should be considered compatible with something expecting a Stack[Animal], given that Dog is a subclass of Animal). This becomes genuinely relevant particularly around Callable type hints and container subtyping, and it's the kind of thing that tends to surface as a confusing type-checker error the first time you hit it. For now, it's enough to know the term exists and roughly what it concerns — the typing module's documentation covers it in detail once you actually encounter a variance-related error in practice.

Protocol for Structural Typing, and TypedDict for Shaped Dictionaries

Protocol: structural typing without inheritance

As introduced in the earlier polymorphism and abstraction articles, typing.Protocol lets a class satisfy an interface simply by having the required methods — no explicit inheritance required at all. This is structural typing ("if it has the right shape, it satisfies the contract"), contrasted with the nominal typing ABCs require (a class must explicitly inherit from the ABC to count as satisfying it).

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return "Drawing a circle"

class Rectangle:
    def draw(self) -> str:
        return "Drawing a rectangle"

def render(shape: Drawable) -> None:
    print(shape.draw())

render(Circle())      # works — Circle has a matching draw() method
render(Rectangle())    # works too — same reason

Neither Circle nor Rectangle inherits from Drawable in any way — a static type checker recognizes both as satisfying the Drawable protocol purely because each happens to have a matching draw() -> str method. This is genuinely valuable when defining an interface against classes you don't own or can't modify to add inheritance to.

@runtime_checkable for isinstance() support

By default, Protocol classes can't be used with isinstance() at runtime — they're purely a static-analysis construct. @runtime_checkable changes that:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str:
        ...

print(isinstance(Circle(), Drawable))   # True — now works at runtime too

Worth a caveat: @runtime_checkable's isinstance() check only verifies that the required methods exist — it doesn't check their actual signatures or return types at runtime, which remains purely a static-checking concern.

TypedDict: precise types for shaped dictionaries

Real-world code frequently works with dictionaries that have a fixed, known set of string keys — parsed JSON, API responses. TypedDict lets you declare exactly that shape:

from typing import TypedDict

class UserResponse(TypedDict):
    id: int
    name: str
    email: str

def process_user(user: UserResponse) -> str:
    return f"{user['name']} ({user['email']})"

user: UserResp {"id": 1, "name": "Alex", "email": "[email protected]"}
print(process_user(user))

A static type checker now knows exactly which keys UserResponse should have, and their expected types — catching a typo'd key name, or a value of the wrong type, as an error, exactly the way it would for a regular class's attributes.

NotRequired and Required for optional keys

Since Python 3.11 (via PEP 655), individual TypedDict keys can be explicitly marked optional or mandatory:

from typing import TypedDict, NotRequired

class UserResponse(TypedDict):
    id: int
    name: str
    email: NotRequired[str]   # this key is optional

user: UserResp {"id": 1, "name": "Alex"}   # valid — email was omitted
A crucial caveat: purely a static-checking construct

This is genuinely important to internalize: a TypedDict is just a plain dict at runtime, with absolutely no actual enforcement. Nothing about declaring UserResponse as a TypedDict prevents you from creating one with the wrong types, or missing required keys, at runtime:

bad_user: UserResp {"id": "not an int", "name": 42}   # no error at runtime at all!

A static type checker like Mypy would flag this immediately as invalid — but Python itself runs this code without any complaint whatsoever, exactly the same "hints are advisory, never enforced" principle covered throughout this entire article and the earlier annotations article.

Putting It to Work: mypy and Runtime Validation

Installing and running Mypy
pip install mypy
mypy src/

Running Mypy against your code analyzes every annotation covered throughout this article — unions, generics, Protocols, TypedDicts — and reports genuine type inconsistencies before you ever actually run the program. This is where all of these annotations actually pay off concretely, rather than remaining purely documentation.

Type stubs for untyped libraries

Many popular third-party libraries either ship their own type hints, or have separate "stub" packages providing them:

pip install types-requests

Installing types-requests gives Mypy the type information it needs to correctly check code using the requests library, even though requests itself might not (or might only partially) include inline type hints in its own source.

Practical guidance on adoption

Don't attempt to type an entire existing codebase all at once — that's a genuinely large, often discouraging undertaking. Start with critical function signatures — the ones most frequently called across your codebase, or the ones where a type mismatch would cause the most damage — and progressively introduce TypedDict or dataclass (covered in the earlier dataclasses article) for structured data as your comfort and confidence grow. Type hints are additive; a partially typed codebase still gets meaningful value from Mypy checking the parts that are typed, without requiring complete coverage before any benefit appears.

Where runtime validation actually fits in

Everything covered in this article — typing, Mypy, Protocol, TypedDict — is purely advisory and static: useful for readers, useful for tooling, but never actually enforced while your program runs, as demonstrated concretely in Section 4. For situations where you genuinely need real, runtime validation and coercion — rejecting malformed data the moment it enters your program, not just flagging it during a separate static-analysis pass — libraries like Pydantic build directly on top of Python's type hint syntax to provide exactly that: declaring a model using familiar type-hint syntax, but with Pydantic actually validating and even coercing incoming data against those types at runtime, raising a real, immediate exception on a mismatch, rather than merely something Mypy would flag if you happened to run it separately. Pydantic (and similar libraries) is worth exploring as the natural next step once you're comfortable with everything covered in this article, specifically for any situation where "advisory, checked separately by a tool" genuinely isn't enough, and you need types actively enforced as your program runs.

PREVIOUS