{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "9e01d4ba",
   "metadata": {},
   "source": "# Optional, Union, and friends\n\nThis notebook covers the more expressive forms you'll reach for as your typing gets more ambitious: `X | None` for optionality, `Literal` for specific-value constraints, `Callable` for function arguments, `TypedDict` for structured dicts, and a few others.\n\nEach of these unlocks a whole category of otherwise untypeable code. Most projects use a handful of them routinely."
  },
  {
   "cell_type": "markdown",
   "id": "b8307eeb",
   "metadata": {},
   "source": "## `X | None` — the optional pattern\n\nAlready seen this briefly. It's worth spending a moment on because it comes up constantly — `None` is Python's standard way of saying \"no value\":"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e17c37eb",
   "metadata": {},
   "outputs": [],
   "source": "def find_user(user_id: int) -> dict | None:\n    # Pretend lookup\n    if user_id == 42:\n        return {\"id\": 42, \"name\": \"Alice\"}\n    return None\n\nuser = find_user(42)\nif user is not None:\n    print(user[\"name\"])\n\nmissing = find_user(99)\nprint(missing)"
  },
  {
   "cell_type": "markdown",
   "id": "87134330",
   "metadata": {},
   "source": "The `dict | None` return type is a promise to callers: \"you might get a dict, you might get None — always handle both\". A type-checker will flag code that calls `user[\"name\"]` without first checking for `None`, catching a whole class of `TypeError: 'NoneType' object is not subscriptable` bugs.\n\n**On older Python**: `Optional[dict]` from `typing` is the same thing. `Optional[X]` is literally defined as `X | None`. Use `X | None` on 3.10+ for consistency with the rest of the `|` union syntax."
  },
  {
   "cell_type": "markdown",
   "id": "25463926",
   "metadata": {},
   "source": "## Narrowing `Optional`\n\nType-checkers follow your control flow and narrow types as you check them:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f02570fb",
   "metadata": {},
   "outputs": [],
   "source": "def find_user(user_id: int) -> dict | None:\n    if user_id == 42:\n        return {\"id\": 42, \"name\": \"Alice\"}\n    return None\n\nuser = find_user(42)\n# Here, the type-checker thinks user is `dict | None`\n\nif user is None:\n    raise ValueError(\"user not found\")\n\n# Past this point, the type-checker has narrowed user to just `dict`\n# — we wouldn't reach here if it were None\nprint(user[\"name\"])   # no type error"
  },
  {
   "cell_type": "markdown",
   "id": "757aba2a",
   "metadata": {},
   "source": "The same narrowing happens inside an `if user is not None:` block, or after an `assert user is not None`. It's one of the genuinely nice features of gradual typing — the type-checker thinks about your code the way you do."
  },
  {
   "cell_type": "markdown",
   "id": "d9716f61",
   "metadata": {},
   "source": "## `Literal` — specific values\n\nSometimes a parameter doesn't take \"any string\" — it takes one of a few specific strings. `Literal[\"a\", \"b\", \"c\"]` expresses exactly that:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4bace39e",
   "metadata": {},
   "outputs": [],
   "source": "from typing import Literal\n\ndef align(text: str, direction: Literal[\"left\", \"right\", \"center\"]) -> str:\n    if direction == \"left\":\n        return text.ljust(20)\n    elif direction == \"right\":\n        return text.rjust(20)\n    return text.center(20)\n\nprint(repr(align(\"hi\", \"left\")))\nprint(repr(align(\"hi\", \"right\")))\nprint(repr(align(\"hi\", \"center\")))\n# align(\"hi\", \"middle\")   # type error: not one of the literal values"
  },
  {
   "cell_type": "markdown",
   "id": "28c5698d",
   "metadata": {},
   "source": "This is far better than `str` — the type-checker flags typos (`\"centre\"` vs `\"center\"`) and your editor autocompletes the valid values. Works for strings, ints, booleans, and None.\n\nFor longer fixed sets of values, an `Enum` is usually the cleaner choice. `Literal` is ideal for ad-hoc \"one of these three strings\" cases."
  },
  {
   "cell_type": "markdown",
   "id": "2f6a4a76",
   "metadata": {},
   "source": "## `Callable` — typing functions passed as arguments\n\nFunctions are first-class in Python — you pass them around, store them in dicts, return them from other functions. `Callable[[ArgType1, ArgType2], ReturnType]` annotates that shape:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "da3c0b1d",
   "metadata": {},
   "outputs": [],
   "source": "from collections.abc import Callable\n\ndef apply_twice(fn: Callable[[int], int], x: int) -> int:\n    return fn(fn(x))\n\nprint(apply_twice(lambda n: n + 1, 5))       # 7\nprint(apply_twice(lambda n: n * n, 3))       # 81"
  },
  {
   "cell_type": "markdown",
   "id": "c576133f",
   "metadata": {},
   "source": "`Callable[[int], int]` reads as \"a callable that takes one `int` and returns an `int`\". The first list is the argument types; the second entry is the return type.\n\nFor \"any callable\" regardless of signature, use `Callable[..., T]` — the literal `...` (ellipsis) means \"any arguments\". Less precise but occasionally necessary.\n\nFor complex signatures — keyword args, optional args — `Callable` gets clunky. At that point either define a `Protocol` or accept that typing is only catching so much."
  },
  {
   "cell_type": "markdown",
   "id": "fc6d545b",
   "metadata": {},
   "source": "## `TypedDict` — structured dicts\n\nA plain `dict[str, int]` says \"all keys are strings, all values are ints\". A `dict[str, str | int | list[int]]` says \"mixed types allowed\" — but then you've lost all useful information about which keys have which types.\n\n`TypedDict` lets you describe a dict where each *specific key* has a specific type. It's the way to type configuration dicts, JSON payloads, and similar record-shaped data:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2c14eedc",
   "metadata": {},
   "outputs": [],
   "source": "from typing import TypedDict\n\nclass UserDict(TypedDict):\n    id: int\n    name: str\n    active: bool\n\nuser: UserDict = {\"id\": 42, \"name\": \"Alice\", \"active\": True}\nprint(user)\nprint(user[\"name\"])\n# user = {\"id\": 42, \"name\": \"Alice\"}   # type error: missing 'active'\n# user[\"id\"] = \"x\"                       # type error: id should be int"
  },
  {
   "cell_type": "markdown",
   "id": "1045cf66",
   "metadata": {},
   "source": "At runtime, a `TypedDict` is just a `dict` — no performance penalty, no restriction on what you can put in it if you bypass the type-checker. It's *purely* a compile-time aid for the type-checker to reason about the shape.\n\nOptional keys go via `NotRequired` (Python 3.11+) or the `total=False` class parameter:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "900ddce9",
   "metadata": {},
   "outputs": [],
   "source": "from typing import TypedDict\ntry:\n    from typing import NotRequired           # Python 3.11+\nexcept ImportError:\n    from typing_extensions import NotRequired   # backport for older\n\nclass UserWithOptionalEmail(TypedDict):\n    id: int\n    name: str\n    email: NotRequired[str]      # this key may or may not be present\n\nalice: UserWithOptionalEmail = {\"id\": 42, \"name\": \"Alice\"}               # ok\nbob: UserWithOptionalEmail = {\"id\": 43, \"name\": \"Bob\", \"email\": \"b@x\"} # ok\nprint(alice)\nprint(bob)"
  },
  {
   "cell_type": "markdown",
   "id": "80d0b85d",
   "metadata": {},
   "source": "**When to reach for a `TypedDict` vs a dataclass**: if the data is already a dict (JSON from an API, a parsed config, a pandas record), `TypedDict` lets you type it without converting to a class. If you're building the data in Python, a dataclass gives you better autocomplete and attribute access. See the [data structure recipe](https://agilearn.co.uk/guides/type-hints/recipes/type-a-data-structure) for the full decision tree."
  },
  {
   "cell_type": "markdown",
   "id": "28dcd0b1",
   "metadata": {},
   "source": "## `Any` vs `object`\n\nBoth mean \"any type\", but they behave very differently for the type-checker:\n\n- `Any` says \"anything goes — don't check, don't narrow, don't flag\". It's the escape hatch from type-checking. Operations on an `Any` are all valid by definition.\n- `object` says \"any Python object, but I only know about the base Object API\". Operations beyond what `object` supports are flagged as errors. Narrowing via `isinstance` works."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e47442af",
   "metadata": {},
   "outputs": [],
   "source": "from typing import Any\n\ndef with_any(x: Any) -> None:\n    x.foo()               # type-checker: fine, Any allows anything\n    x + 1\n    x[\"key\"]\n\ndef with_object(x: object) -> None:\n    pass\n    # x.foo()             # type error: object has no .foo()\n    # x + 1               # type error: unsupported operand types\n    # But this works:\n    if isinstance(x, str):\n        x.upper()         # narrowed to str, all str methods available\n\nprint(\"Both compile — the difference is what the type-checker would flag.\")"
  },
  {
   "cell_type": "markdown",
   "id": "6a584a3d",
   "metadata": {},
   "source": "Rule of thumb: prefer `object` over `Any` whenever possible. `object` says \"I don't care about the type\" but still lets the checker help you; `Any` is the off-switch."
  },
  {
   "cell_type": "markdown",
   "id": "d4cbb905",
   "metadata": {},
   "source": "## `type[X]` — the class itself, not an instance\n\nOccasionally you want to annotate \"the class `X`, not an instance of `X`\". `type[X]` is how:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8a0a64ab",
   "metadata": {},
   "outputs": [],
   "source": "def make_instance(cls: type[int], value: str) -> int:\n    return cls(value)     # calling the class to construct an instance\n\nx = make_instance(int, \"42\")\nprint(x, type(x))"
  },
  {
   "cell_type": "markdown",
   "id": "2cc53911",
   "metadata": {},
   "source": "Common in factory patterns, dependency injection, and whenever you pass a class as an argument rather than an instance. `type[Exception]` is \"any exception class\"."
  },
  {
   "cell_type": "markdown",
   "id": "40095de4",
   "metadata": {},
   "source": "## `Final`, `ClassVar`, and a few more\n\nWorth knowing but you won't reach for them often:\n\n- **`Final[T]`** — declares a variable that shouldn't be reassigned. The type-checker flags reassignment. Useful for constants.\n- **`ClassVar[T]`** — inside a class, distinguishes a class-level attribute (shared by all instances) from an instance attribute. Matters for dataclasses, where `ClassVar` fields are excluded from `__init__`.\n- **`NewType(\"UserId\", int)`** — creates a distinct type from an existing one. A `UserId` is an `int` at runtime but the type-checker treats them as different, preventing you from passing a random int where a user ID is expected.\n- **`Protocol`** — structural typing (duck typing with type-checker support). \"Anything with a `.read()` method that returns bytes.\" Covered in the [`typing` reference](https://agilearn.co.uk/guides/type-hints/reference/typing-module-reference)."
  },
  {
   "cell_type": "markdown",
   "id": "69251f5e",
   "metadata": {},
   "source": "## Exercise\n\nAnnotate the following. Use the most precise form available."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8fef45ca",
   "metadata": {},
   "outputs": [],
   "source": "# 1. A function that takes 'GET', 'POST', or 'DELETE' and returns a request string\ndef build_request(method, path):\n    return f\"{method} {path}\"\n\n# 2. A function that looks up a user by id and returns the user dict or None\ndef find_user(user_id, db):\n    return db.get(user_id)\n\n# 3. A retry helper that takes a function with no arguments, a number of attempts,\n#    and returns whatever the function returns\ndef retry(fn, attempts=3):\n    last_error = None\n    for _ in range(attempts):\n        try:\n            return fn()\n        except Exception as e:\n            last_error = e\n    raise last_error\n\n# 4. A config dict with 'host' (str), 'port' (int), and optional 'debug' (bool)\nconfig = {\"host\": \"localhost\", \"port\": 8080}\n\nprint(build_request(\"GET\", \"/users\"))\nprint(find_user(1, {1: {\"name\": \"Alice\"}}))\nprint(retry(lambda: 42))"
  },
  {
   "cell_type": "markdown",
   "id": "d0d8412a",
   "metadata": {},
   "source": "<details>\n<summary>Solution</summary>\n\n```python\nfrom typing import Literal, TypedDict, NotRequired, TypeVar\nfrom collections.abc import Callable, Mapping\n\n# 1. Literal for the method; str for the path\nMethod = Literal[\"GET\", \"POST\", \"DELETE\"]\ndef build_request(method: Method, path: str) -> str:\n    return f\"{method} {path}\"\n\n# 2. Optional return; Mapping for the db parameter\ndef find_user(user_id: int, db: Mapping[int, dict]) -> dict | None:\n    return db.get(user_id)\n\n# 3. TypeVar so the return type matches what fn returns\nT = TypeVar(\"T\")\ndef retry(fn: Callable[[], T], attempts: int = 3) -> T:\n    last_error: Exception | None = None\n    for _ in range(attempts):\n        try:\n            return fn()\n        except Exception as e:\n            last_error = e\n    assert last_error is not None\n    raise last_error\n\n# 4. TypedDict with a NotRequired key\nclass Config(TypedDict):\n    host: str\n    port: int\n    debug: NotRequired[bool]\n\nconfig: Config = {\"host\": \"localhost\", \"port\": 8080}\n```\n\nNotes:\n\n- `Method = Literal[...]` extracts the type alias so it's reusable and self-documenting.\n- `retry`'s `T` ties the return type to whatever `fn` produces — pass a `Callable[[], int]`, get an `int`.\n- The `assert last_error is not None` inside `retry` narrows the type for the following `raise` statement.\n</details>"
  },
  {
   "cell_type": "markdown",
   "id": "cdaf2835",
   "metadata": {},
   "source": "## Recap\n\n- `X | None` (or `Optional[X]`) for parameters or returns that might be missing.\n- `Literal[\"a\", \"b\"]` for specific-value constraints.\n- `Callable[[Arg1, Arg2], Return]` for functions passed as arguments.\n- `TypedDict` for dicts where each key has a known type.\n- `Any` disables checking; `object` is \"any Python object\" with checking still active.\n- `type[X]` for the class itself rather than an instance.\n\nThat's the shape of the language. The [Recipes](https://agilearn.co.uk/guides/type-hints/recipes) and [Reference](https://agilearn.co.uk/guides/type-hints/reference) cover specific tasks and lookup tables."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}