Skip to main content

Python Static Analysis in Practice: Practice Problems & Exercises

Practice: Static Analysis in Practice

11 problems3 Easy4 Medium4 Hard65–85 min
← Back to lesson

Easy

#1Fix Common Mypy ErrorsEasy
mypytype-errorannotationfix

The code below contains several common mypy errors. Fix them with proper type annotations and Optional usage so it runs cleanly.

Python
from typing import Optional


def greet(name: Optional[str] = None) -> str:
    # Fix: handle None explicitly
    if name is None:
        name = "World"
    return f"Hello, {name}!"


def count_items(items: list) -> int:
    # Fix: return annotation was missing
    return len(items)


def get_first_name(full_name: str) -> Optional[str]:
    # Fix: may return None if there are no spaces
    parts = full_name.split(" ")
    return parts[0] if parts else None


result = greet("Alice")
print(f"greeting: {result}")

name = get_first_name("Alice Smith")
print(f"name: {name}")

count = count_items([1, 2, 3])
print(f"count: {count}")
Expected Output
greeting: Hello, Alice!
name: Alice
count: 3
Hints

Hint 1: Mypy flags variables where the type widens unexpectedly (e.g., assigning None then reassigning).

Hint 2: Use Optional[str] for variables that start as None but get a string later.


#2Use cast() for Type Narrowing Without GuardEasy
casttype-narrowingstatic-analysis

Use typing.cast() to assert the type of values returned from a weakly-typed dict lookup, making the type checker happy without adding runtime overhead.

Python
from typing import Any, Dict, cast


def load_config() -> Dict[str, Any]:
    return {"user_id": 42, "name": "Alice", "active": True}


config = load_config()

# Without cast: config["user_id"] is Any — no int-specific operations
user_id = cast(int, config["user_id"])
name = cast(str, config["name"])

print(f"user_id: {user_id}")
print(f"user_id type: {type(user_id).__name__}")
print(f"name: {name}")

# cast has no runtime effect — it is purely a type system hint
assert user_id == config["user_id"]
Expected Output
user_id: 42
user_id type: int
name: Alice
Hints

Hint 1: typing.cast(T, value) tells the type checker to treat value as T at the call site. It has zero runtime effect.

Hint 2: Use cast when you know more about the type than the checker can infer — e.g., after a dict lookup.


#3Annotate a Function with Complex Return TypeEasy
annotationUnionOptionalreturn-type

Annotate parse_value with its full return type using Union and Optional, then implement it to try int, float, and str coercion in order.

Python
from typing import Union, Optional


def parse_value(raw: str) -> Optional[Union[int, float, str]]:
    if not raw:
        return None
    try:
        return int(raw)
    except ValueError:
        pass
    try:
        return float(raw)
    except ValueError:
        pass
    return raw


for test in ["42", "3.14", "hello", ""]:
    result = parse_value(test)
    type_name = type(result).__name__
    print(f"parse_value({test!r}) = {result} ({type_name})")
Expected Output
parse_value('42') = 42 (int)
parse_value('3.14') = 3.14 (float)
parse_value('hello') = hello (str)
parse_value('') = None (NoneType)
Hints

Hint 1: The return type is Union[int, float, str, None] — you can also write Optional[Union[int, float, str]].

Hint 2: Try int() first, then float(), then return the string as-is. Return None for empty input.


Medium

#4Incremental Typing with type: ignoreMedium
type-ignoregradual-typingmypylegacy-code

Demonstrate responsible use of # type: ignore with specific error codes. Show where it is appropriate and where a proper annotation is better.

Python
from typing import Any


# Legacy function — returns Any, no annotations
def legacy_compute(x, y):
    return x * y


def typed_wrapper(a: int, b: int) -> int:
    # legacy_compute returns Any; cast would be cleaner but ignore is acceptable temporarily
    result: int = legacy_compute(a, b)  # type: ignore[assignment]
    return result


def legacy_sum(*args):
    total = 0
    for arg in args:
        total += arg
    return total


# Annotate what we control; leave # type: ignore for what we don't
result: int = typed_wrapper(7, 6)
computed: int = legacy_compute(10, 10)  # type: ignore[assignment]
legacy_total: int = legacy_sum(1, 2, 3, 4, 5)  # type: ignore[assignment]

print(f"result: {result}")
print(f"computed: {computed}")
print(f"legacy_sum: {legacy_total}")
Expected Output
result: 42
computed: 100
legacy_sum: 15
Hints

Hint 1: Use # type: ignore[assignment] to suppress specific mypy errors on lines you cannot fix yet.

Hint 2: Prefer the most specific error code (e.g., [arg-type], [return-value]) over a blanket # type: ignore.


#5Write a .pyi Stub — Simulate Untyped LibraryMedium
stub-file.pyistatic-analysisoverload

Simulate writing stub annotations for an untyped library function by wrapping it with @overload declarations that provide full type information to static checkers.

Python
from typing import Union, overload


# Simulated "untyped library function"
def _untyped_compute(a, b):
    return a + b


# Stub-style overloads (would live in .pyi in a real project)
@overload
def compute(a: int, b: int) -> int: ...
@overload
def compute(a: float, b: float) -> float: ...
@overload
def compute(a: str, b: str) -> str: ...


def compute(
    a: Union[int, float, str],
    b: Union[int, float, str],
) -> Union[int, float, str]:
    return _untyped_compute(a, b)


print(f"compute(5, 3) = {compute(5, 3)}")
print(f"compute(5.0, 3.0) = {compute(5.0, 3.0)}")
print(f"compute('hello', ' world') = {compute('hello', ' world')}")
Expected Output
compute(5, 3) = 8
compute(5.0, 3.0) = 8.0
compute('hello', ' world') = hello world
Hints

Hint 1: A .pyi stub provides type information without running code. Simulate this by defining a typed wrapper around an untyped function.

Hint 2: The @overload stubs mirror what a .pyi would declare for an untyped library function.


#6Reveal Types for Debugging Static AnalysisMedium
reveal_typemypydebugstatic-analysis

Demonstrate how reveal_type() is used to debug type inference. Show how to use it safely at runtime while still benefiting from it in static analysis.

Python
from typing import List


def reveal_type_safe(value, label: str = "") -> None:
    """Runtime-compatible reveal_type simulation."""
    type_name = type(value).__name__
    if label:
        print(f"reveal_type({label}): {type_name}")
    else:
        print(f"reveal_type: {type_name}")


x: int = 42
items: List[int] = [1, 2, 3]
first = items[0]          # inferred: int
doubled = [v * 2 for v in items]  # inferred: List[int]

reveal_type_safe(x, "x")
reveal_type_safe(items, "items")
reveal_type_safe(first, "first")
reveal_type_safe(doubled, "doubled")

print(f"x = {x}")
print(f"items = {items}")
print(f"first = {first}")
print(f"doubled = {doubled}")
Expected Output
x = 42
items = [1, 2, 3]
first = 1
doubled = [2, 4, 6]
Hints

Hint 1: reveal_type(x) is a mypy/pyright special — it prints the inferred type of x. At runtime it calls print but with a type annotation message.

Hint 2: Wrap it in a try/except NameError for runtime compatibility when not running under mypy.


#7final and ClassVar AnnotationsMedium
FinalClassVarstatic-analysisclass-variable

Use Final for constants and ClassVar for class-level counters. Demonstrate that mypy would flag reassignment of Final variables (simulate the check at runtime).

Python
from typing import Final, ClassVar


# Module-level constants
PI: Final = 3.14159
MAX_RETRIES: Final[int] = 3


class Connection:
    _instance_count: ClassVar[int] = 0
    MAX_CONNECTIONS: ClassVar[Final[int]] = 10   # type: ignore[misc]

    def __init__(self, host: str) -> None:
        self.host = host
        Connection._instance_count += 1

    @classmethod
    def count(cls) -> int:
        return cls._instance_count


c1 = Connection("localhost")
c2 = Connection("remotehost")

print(f"PI = {PI}")
print(f"MAX_RETRIES = {MAX_RETRIES}")
print(f"instance count = {Connection.count()}")

# Simulated Final reassignment check
def check_final_reassignment() -> None:
    """Simulates what mypy would catch: reassignment of Final."""
    try:
        # In real code, mypy would flag this before runtime
        globals_copy = dict(globals())
        if "PI" in globals_copy:
            raise TypeError("cannot reassign final")
    except TypeError as e:
        print(f"TypeError on assignment: {e}")


check_final_reassignment()
Expected Output
PI = 3.14159
MAX_RETRIES = 3
instance count = 2
TypeError on assignment: cannot reassign final
Hints

Hint 1: Final[T] marks a variable as constant — mypy raises an error if you reassign it.

Hint 2: ClassVar[T] distinguishes class-level variables from instance variables in type annotations.


Hard

#8Type-Safe Configuration with TypedDict and ValidationHard
TypedDictstatic-analysisvalidationcastget_type_hints

Build a load_app_config function that loads a TypedDict-typed config, validates every field, and returns a strongly-typed object that mypy fully understands.

Python
from typing import Any, Dict, cast, get_type_hints
try:
    from typing import TypedDict
except ImportError:
    from typing_extensions import TypedDict


class AppConfig(TypedDict):
    env: str
    workers: int
    debug: bool
    host: str
    port: int


def load_app_config(raw: Dict[str, Any]) -> AppConfig:
    hints = get_type_hints(AppConfig)
    errors = []
    coerced: Dict[str, Any] = {}

    defaults: Dict[str, Any] = {
        "env": "development",
        "workers": 1,
        "debug": True,
        "host": "localhost",
        "port": 8000,
    }

    for key, expected in hints.items():
        if key in raw:
            value = raw[key]
            if not isinstance(value, expected):
                try:
                    value = expected(value)
                except (ValueError, TypeError):
                    errors.append(f"{key}: cannot convert {value!r} to {expected.__name__}")
                    continue
            coerced[key] = value
        elif key in defaults:
            coerced[key] = defaults[key]
        else:
            errors.append(f"{key}: missing required field")

    if errors:
        raise ValueError("Config errors: " + "; ".join(errors))

    return cast(AppConfig, coerced)


class ConfigView:
    def __init__(self, cfg: AppConfig) -> None:
        self._cfg = cfg

    def __repr__(self) -> str:
        c = self._cfg
        return f"AppConfig(env={c['env']}, workers={c['workers']}, debug={c['debug']})"


raw_input: Dict[str, Any] = {
    "env": "production",
    "workers": "4",   # string — should be coerced to int
    "debug": False,
    "host": "0.0.0.0",
    "port": 80,
}

cfg = load_app_config(raw_input)
view = ConfigView(cfg)

print(f"Config loaded: {view}")
print(f"Env: {cfg['env']}")
print(f"Worker count valid: {isinstance(cfg['workers'], int)}")
print(f"Debug disabled in prod: {not cfg['debug']}")
Expected Output
Config loaded: AppConfig(env=production, workers=4, debug=False)
Env: production
Worker count valid: True
Debug disabled in prod: True
Hints

Hint 1: Use TypedDict to define AppConfig. A loader function parses raw dict, validates types with get_type_hints, and casts to AppConfig.

Hint 2: Return a typed AppConfig object that lets the static checker know exact field types.


#9Gradual Typing Migration PlanHard
gradual-typingmypymigrationannotationAny

Demonstrate a three-phase gradual typing migration of a small analytics function: untyped, partially typed with Any, and fully typed.

Python
from typing import Any, List, Dict, Optional
import inspect


# Phase 1: untyped — no annotations at all
def compute_v1(data, weights):
    total = sum(x * w for x, w in zip(data, weights))
    return total


# Phase 2: partially typed — parameters annotated, complex parts as Any
def compute_v2(data: List[Any], weights: List[Any]) -> Any:
    total: Any = sum(x * w for x, w in zip(data, weights))
    return total


# Phase 3: fully typed — complete, specific annotations
def compute_v3(data: List[float], weights: List[float]) -> float:
    return sum(x * w for x, w in zip(data, weights))


def type_coverage(func) -> float:
    """Measure % of params+return that are not Any or missing."""
    hints = {}
    try:
        import typing
        hints = typing.get_type_hints(func)
    except Exception:
        pass
    sig = inspect.signature(func)
    params = [p for p in sig.parameters if p != "self"]
    total = len(params) + 1  # +1 for return
    if total == 0:
        return 0.0
    annotated = sum(
        1 for name in (list(params) + ["return"])
        if name in hints and hints[name] is not Any
    )
    return annotated / total


data = [1.0, 2.0, 3.0, 4.0, 5.0]
weights = [1.0, 1.0, 1.0, 1.0, 1.0]

print(f"Phase 1 (untyped): result = {compute_v1(data, weights):.0f}")
print(f"Phase 2 (partially typed): result = {compute_v2(data, weights):.0f}")
print(f"Phase 3 (fully typed): result = {compute_v3(data, weights):.0f}")

cov1 = type_coverage(compute_v1)
cov3 = type_coverage(compute_v3)
print(f"Type coverage improved: {cov3 > cov1}")
Expected Output
Phase 1 (untyped): result = 25
Phase 2 (partially typed): result = 25
Phase 3 (fully typed): result = 25
Type coverage improved: True
Hints

Hint 1: Start with Any everywhere, then progressively narrow to specific types.

Hint 2: Track coverage as the percentage of function parameters and returns that are not Any.


#10Protocol + Generic + Static Analysis IntegrationHard
ProtocolGenericTypeVarstatic-analysiscast

Design a generic storage Protocol and two implementations. Show that mypy can reason about which concrete type is returned through a typed service layer.

Python
from typing import TypeVar, Generic, Optional, Protocol, Dict, Any
import json

T = TypeVar("T")


class Serializable(Protocol):
    def to_dict(self) -> Dict[str, Any]: ...

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "Serializable": ...


class Storage(Protocol[T]):
    def save(self, key: str, value: T) -> None: ...
    def load(self, key: str) -> Optional[T]: ...


class MemoryStorage(Generic[T]):
    def __init__(self) -> None:
        self._store: Dict[str, T] = {}

    def save(self, key: str, value: T) -> None:
        print(f"MemoryStorage saved key={key}")
        self._store[key] = value

    def load(self, key: str) -> Optional[T]:
        result = self._store.get(key)
        print(f"MemoryStorage loaded key={key} -> {result}")
        return result


class JSONStorage(Generic[T]):
    def __init__(self, serializer, deserializer) -> None:
        self._raw: Dict[str, str] = {}
        self._serialize = serializer
        self._deserialize = deserializer

    def save(self, key: str, value: T) -> None:
        print(f"JSONStorage saved key={key}")
        self._raw[key] = json.dumps(self._serialize(value))

    def load(self, key: str) -> Optional[T]:
        if key not in self._raw:
            return None
        result = self._deserialize(json.loads(self._raw[key]))
        print(f"JSONStorage loaded key={key} -> {result}")
        return result


class User:
    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name = name

    def to_dict(self) -> Dict[str, Any]:
        return {"id": self.id, "name": self.name}

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "User":
        return cls(data["id"], data["name"])

    def __repr__(self) -> str:
        return f"User(id={self.id}, name={self.name!r})"


json_store: JSONStorage[User] = JSONStorage(
    serializer=lambda u: u.to_dict(),
    deserializer=User.from_dict,
)

mem_store: MemoryStorage[User] = MemoryStorage()

u1 = User(1, "Alice")
u2 = User(2, "Bob")

json_store.save("user:1", u1)
loaded_json = json_store.load("user:1")

mem_store.save("user:2", u2)
loaded_mem = mem_store.load("user:2")

print(f"Both backends return correct type: {isinstance(loaded_json, User) and isinstance(loaded_mem, User)}")
Expected Output
JSONStorage saved key=user:1
JSONStorage loaded key=user:1 -> User(id=1, name='Alice')
MemoryStorage saved key=user:2
MemoryStorage loaded key=user:2 -> User(id=2, name='Bob')
Both backends return correct type: True
Hints

Hint 1: Define a Storage[T] Protocol with save(key: str, value: T) -> None and load(key: str) -> Optional[T].

Hint 2: Implement JSONStorage[T] and MemoryStorage[T] — both satisfy the protocol. A typed StorageService uses either backend.


#11CI-Ready Typed Codebase — Full IntegrationHard
mypyCItypingProtocolTypedDictoverloadintegration

Build a production-grade typed pipeline combining TypedDict, Protocol, TypeGuard, and overload — the kind that would pass mypy --strict in CI.

Python
from typing import Dict, Any, List, Optional, Protocol, overload, Literal, Union
try:
    from typing import TypedDict, TypeGuard
except ImportError:
    from typing_extensions import TypedDict, TypeGuard


# --- Data shapes ---

class EventRecord(TypedDict):
    event_id: str
    payload: str
    version: int


# --- Processing Strategy Protocol ---

class ProcessingStrategy(Protocol):
    @property
    def name(self) -> str: ...

    def process(self, data: str) -> str: ...


# --- Concrete strategies ---

class UpperCaseStrategy:
    @property
    def name(self) -> str:
        return "UPPERCASE"

    def process(self, data: str) -> str:
        return data.upper()


class ReverseStrategy:
    @property
    def name(self) -> str:
        return "REVERSE"

    def process(self, data: str) -> str:
        return data[::-1]


# --- TypeGuard for input validation ---

def is_event_record(data: Dict[str, Any]) -> "TypeGuard[EventRecord]":
    return (
        isinstance(data.get("event_id"), str)
        and isinstance(data.get("payload"), str)
        and isinstance(data.get("version"), int)
    )


# --- Overloaded output formatter ---

@overload
def format_output(result: str, verbose: Literal[True]) -> Dict[str, Any]: ...
@overload
def format_output(result: str, verbose: Literal[False]) -> str: ...


def format_output(result: str, verbose: bool = False) -> Union[str, Dict[str, Any]]:
    if verbose:
        return {"result": result, "length": len(result)}
    return result


# --- Typed storage ---

_store: Dict[str, str] = {}


def store_result(key: str, value: str) -> None:
    _store[key] = value


# --- Main pipeline ---

def process_event(
    raw: Dict[str, Any],
    strategy: ProcessingStrategy,
) -> Optional[str]:
    print(f"Pipeline step 1: raw data validated")
    if not is_event_record(raw):
        return None

    record: EventRecord = raw
    print(f"Pipeline step 2: typed as EventRecord")

    processed = strategy.process(record["payload"])
    print(f"Pipeline step 3: processed with strategy {strategy.name}")

    store_result(record["event_id"], processed)
    print(f"Pipeline step 4: result stored")

    return format_output(processed, verbose=False)


raw_event: Dict[str, Any] = {
    "event_id": "evt-001",
    "payload": "hello world",
    "version": 1,
}

strategy = UpperCaseStrategy()
output = process_event(raw_event, strategy)

print(f"Output: {output}")
print(f"All type-safe: {output == 'HELLO WORLD'}")
Expected Output
Pipeline step 1: raw data validated
Pipeline step 2: typed as EventRecord
Pipeline step 3: processed with strategy UPPERCASE
Pipeline step 4: result stored
Output: HELLO WORLD
All type-safe: True
Hints

Hint 1: Combine TypedDict for data shapes, Protocol for the processing strategy, TypeGuard for input validation, and @overload for the output function.

Hint 2: Chain all four techniques in a single process_event pipeline that would pass mypy --strict.

© 2026 EngineersOfAI. All rights reserved.