---
title: 'Type Hints'
source: 'https://academia.sh/en/courses/python-object-and-types/type-hints'
course: 'Object-Oriented Python and Types'
language: en
updated: '2026-08-17T18:10:30+00:00'
license: 'CC BY-SA 4.0'
---

# Type Hints

The annotation is written into the code, and the interpreter stores it without checking it: four of five calls are violations and the number that errors at runtime is zero, yet every annotation remains readable at run time.

The previous lesson declared and tested a contract in code. A test looking for
lineage asked about a class's declared ancestor, a test looking for a method looked
at the names the object actually carried; both gave their result during a **run**,
on the object sitting in hand. The contract was in the code, and the tester was
code too.

This lesson's question is born from another way the contract gets written. Writing
"a string comes in here, an integer comes out here" on a function's signature is
also a contract — but this contract is written not into a class tree or a method
name, it is written into **the writing itself**. The course's axis is the same
here: **who checks the claim?** This lesson measures one layer of the answer, the
lowest one: run time.

## How an Annotation Is Written

A **type hint** is the contract stating which type of value a name is expected to
carry. An **annotation** is that hint written into the code: after a function
parameter, a colon and a type; for the return, `->` and a type.

```python
def print_age(name: str, age: int) -> str:
    return f"{name}:{age}"
```

The same notation is valid outside a function too. Inside a class body, `name: str`
declares a field; at module level, `counter: int = 0` declares a variable. There is
a fourth place too: a local variable inside a function's body. The measurement
separates these four, because the notation being the same does not mean the result
is the same.

The Programming Fundamentals course established type conversion and dynamic
typing; which object a name binds to at run time was measured there and is not
repeated here. The **TypeScript** course asked the same question too, in a
language whose type layer is checked before execution. The difference here can be
said in one sentence: in Python, an annotation **does not stop execution**; what
this lesson measures is exactly the count of that not-stopping.

## What the Interpreter Does with an Annotation

The interpreter reads the annotation, evaluates it, and **stores** it. Where it
stores it is the function's `__annotations__` dictionary; the class and the module
have a dictionary of the same name too. This dictionary is an ordinary dictionary
and can be read at run time.

What it does not do is check it. When a call comes in, whether the object bound to
the parameter matches the annotation is not looked at; a non-matching object gets
bound too, and the function body runs too. This has a direct consequence: what is
written in the annotation does not even have to be **a type**. The measurement
shows this too — a function whose return annotation is written as a number is
defined without a problem, called without a problem, and that number sits in the
dictionary exactly as written.

The measurement asks two questions separately. First: wherever an annotation is
written, does it get stored? Second: of calls that run against the annotation, how
many error?

The measurement's assumptions:

- **TL1** — The oracle is the setup itself: which of the five calls carries a type
  violation and which carries a domain violation was decided by the lesson's
  author. The measurement takes this classification from the setup, not from a
  tool.
- **TL2** — A **type violation** is the bound object not being of the annotated
  type. A **domain violation** is the object's type being correct while its value
  breaks the domain rule: an empty name, a negative age.
- **TL3** — Two of the five calls carry a type violation, two carry a domain
  violation, one is clean. The arrays come from this course's shared setup and are
  not changed in this lesson.
- **TL4** — The run-time layer's measure is whether the call raises an exception.
  The body is nothing but a string formatting, and formatting accepts every type;
  what is measured is not what the body does, it is **whether the annotation gets
  in the call's way**.
- **TL5** — In the storage measurement, what is counted is the number of items in
  the `__annotations__` dictionary; the return annotation is an item too.
- **TL6** — Saying "nowhere" for a local variable's annotation means no such
  record exists on the function object; the measure is taken through
  `__annotations__`.
- **TL7** — In the compound-annotation measurement, the only question asked is
  whether the stored object can be given directly to an `isinstance` test. If the
  test itself raises an exception rather than failing, "no" is written.
- **TL8** — The error in the last line is an error the **body** produces, not the
  annotation; the measurement runs that call separately to tell the two apart.

## Measurement

```python
"""Type hints: where the annotation is stored, what is checked at run time."""

counter: int = 0


def describe_person(name: str, age: int) -> str:
    return f"{name}:{age}"


class Record:
    name: str
    age: int = 0


def local_annotation():
    temp: int = 1
    return temp


def stored(obj):
    return len(getattr(obj, "__annotations__", {}))


print(f"{'location of annotation':<24s} {'stored where':<26s} {'readable count':>14s}")
LOCATIONS = (("function signature", "function.__annotations__", stored(describe_person)),
          ("class body", "Class.__annotations__", stored(Record)),
          ("module level", "module __annotations__", len(__annotations__)),
          ("function body", "nowhere", stored(local_annotation)))
for location, stored_where, count in LOCATIONS:
    print(f"  {location:<22s} {stored_where:<26s} {count:14d}")
print(f"function annotation: {describe_person.__annotations__}")


def trial(x: "not a number annotation") -> 3:
    return x


print(f"non-type annotation stored: {trial.__annotations__}")
print(f"trial(41) -> {trial(41)}")

CALLS = (
    ("name", 30),          # valid
    ("name", "thirty"),    # type violation: age is a string
    (7, 30),               # type violation: name is a number
    ("name", -5),          # type valid, domain violation: age negative
    ("", 30),              # type valid, domain violation: name empty
)


def domain_rule(name, age):
    errors = []
    if isinstance(name, str) and not name:
        errors.append("name empty")
    if isinstance(age, int) and age < 0:
        errors.append("age negative")
    return errors


def runtime(calls):
    errors, results = 0, []
    for arg in calls:
        try:
            results.append(describe_person(*arg))
        except Exception:
            errors += 1
            results.append(None)
    return errors, results


type_violations = [i for i, a in enumerate(CALLS)
              if not isinstance(a[0], str) or not isinstance(a[1], int)]
domain_violations = [i for i, a in enumerate(CALLS) if domain_rule(*a)]
errors, results = runtime(CALLS)

print()
print(f"{'call':>6s} {'type violation':>15s} {'domain violation':>17s} "
      f"{'runtime':>10s} {'returned value':>17s}")
for i, arg in enumerate(CALLS):
    print(f"{i:6d} {str(i in type_violations):>15s} {str(i in domain_violations):>17s} "
          f"{'no error':>10s} {results[i]!r:>17s}")
print()
print(f"calls {len(CALLS)}, violating {len(set(type_violations) | set(domain_violations))}, "
      f"type violation {len(type_violations)}, domain violation {len(domain_violations)}, "
      f"erroring at runtime {errors}")


def total(numbers: list[int]) -> int:
    return sum(numbers)


COMPOUND = (("list[int]", list[int], [1, 2]),
           ("str | None", str | None, "name"),
           ("dict[str, int]", dict[str, int], {"a": 1}),
           ("tuple[int, ...]", tuple[int, ...], (1, 2)))

print()
print(f"{'compound annotation':<18s} {'testable with isinstance':>25s}")
for name, obj, example in COMPOUND:
    try:
        isinstance(example, obj)
        testable = "yes"
    except TypeError:
        testable = "no"
    print(f"  {name:<16s} {testable:>25s}")
print(f"total's annotation: {total.__annotations__}")
try:
    total(["a", "b"])
    print("total(['a', 'b']) -> no error")
except TypeError:
    print("total(['a', 'b']) -> the body errored, not the annotation")
```

```
location of annotation   stored where               readable count
  function signature     function.__annotations__                3
  class body             Class.__annotations__                   2
  module level           module __annotations__                  1
  function body          nowhere                                 0
function annotation: {'name': <class 'str'>, 'age': <class 'int'>, 'return': <class 'str'>}
non-type annotation stored: {'x': 'not a number annotation', 'return': 3}
trial(41) -> 41

  call  type violation  domain violation    runtime    returned value
     0           False             False   no error         'name:30'
     1            True             False   no error     'name:thirty'
     2            True             False   no error            '7:30'
     3           False              True   no error         'name:-5'
     4           False              True   no error             ':30'

calls 5, violating 4, type violation 2, domain violation 2, erroring at runtime 0

compound annotation  testable with isinstance
  list[int]                               no
  str | None                             yes
  dict[str, int]                          no
  tuple[int, ...]                         no
total's annotation: {'numbers': list[int], 'return': <class 'int'>}
total(['a', 'b']) -> the body errored, not the annotation
```

## Stored and Not Stored

The upper table puts the four locations side by side, and there is a sharp
boundary between three of them and one.

A function signature stores **3** annotations: two parameters and the return.
A class body stores **2**, module level **1**. In these three places, the
annotation turns into an attribute of the object it was written on and can be read
from there while the program runs.

A local annotation inside a function body, by contrast, gives **0**. The line
`temp: int = 1` is a valid Python line, it performs the assignment, it raises no
error — but it leaves no record behind. A local name's lifetime is bound to the
call, and its annotation's lifetime is bound to just as much. Had the same function
been called a thousand times, a thousand separate local names would have been born
and died, and the stored annotation count would still have stayed at **0**. At
class and module level the situation is reversed: the annotation there is
processed once, the moment the definition is made, and it stays for the object's
whole lifetime.

The distinction that follows is the ground the later lessons stand on: **not every
annotation written into the source is visible at run time.** A layer reading the
source sees all four locations; a layer reading the running program sees only
three.

The non-type annotation's line hits the same point from another angle. The `3`
written as the return annotation sits in the dictionary as `3`, and the call
`trial(41)` returns **41**. The interpreter does not look at whether the
annotation is meaningful, because it has no side that interprets the annotation.

## Four Violations, Zero Errors

The lower table gives the lesson's central number. **Two** of the five calls carry
a type violation: in one, a string stands in for the age, in the other, a number
stands in for the name. **Two** carry a domain violation: age **-5**, name an
empty string. **4** calls total are violations, and one is clean.

The runtime column says "no error" on all five of the five lines. The count is
**0**. The fourth column also shows why: every call returns a value. `'name:thirty'`
gets produced, because formatting accepts a string too. `'7:30'` gets produced,
because formatting accepts a number too. `'name:-5'` and `':30'` get produced,
because **-5** is an integer and an empty string is a string.

**In this measurement, there is no layer catching the violations.** The answer to
who caught them is singular here, and it is empty: run time catches **0**. The
oracle knows the four violations because we wrote the setup; the program does not.

An objection is fair here: the body is lenient because it is nothing but a string
formatting; a different body would have errored on the wrong type. True, but that
is not what is measured. Had such a body errored, what produced the error would
still have been the **operation inside the body**, not the annotation — and the
error would have come after the function was called, with part of the work already
done. The only thing the annotation could have provided is the call **never
starting at all**; in the measurement, no call fails to start.

The two violation kinds being counted separately is not a detail, it is the axis
the rest of the course stands on. The annotation `name: str` does not exclude an
empty string — an empty string is a string. The annotation `age: int` does not
exclude a negative number — **-5** is an integer. **A type being correct does not
mean a value is valid**, and this is not a shortcoming of annotations, it is their
definition: an annotation declares the type, not the value.

## A Compound Annotation Is Not a Check

An annotation does not have to be a single type name. `list[int]` declares "a list
of integers," `dict[str, int]` "a dict whose keys are strings and values are
integers," `str | None` "a string or nothing," `tuple[int, ...]` "a tuple made of
as many integers as wanted." All four are valid annotations, and all four sit as
an object inside `__annotations__` — the last line shows this: the `total`
function's annotation carries a `list[int]` object.

The third table measures these objects' limit at run time. **Three** of the four
cannot be given directly to an `isinstance` test; the test does not return a
result, it raises an exception. Only `str | None` gives "yes," because a union
merely places two type names side by side and requires no look inside the
content.

Why this is so is not a design question, it is a cost question.
`isinstance(x, list)` is answered with a single glance; `isinstance(x, list[int])`
would mean a thousand tests on a thousand-item list. A test's cost grows with the
container's size, and the language does not silently take on that cost.

The result reinforces the lesson's central claim: **an annotation is not a check,
it is a record.** The last line shows this directly — the call `total(["a", "b"])`
errors, but what produces the error is not the annotation, it is **the summation
operation inside the body**. Had there been no annotation, the same error would
have come out at the same point; even with the annotation present, the call was
not blocked.

## What Use Is an Annotation That Is Never Checked

The zero raises a question: why write a declaration that is never checked?

The answer sits in the line in the middle of the output. `describe_person.__annotations__`
can be read at run time and gives `str` for `name`, `int` for `age`, `str` for the
return. The annotation is not checked, but it is **not lost either**. It sits in a
dictionary, under a known attribute name, reachable while the program runs.

This is the fact the next three lessons rest on entirely. A program can be written
that reads the annotation and looks for a type mismatch; a program can be written
that reads the source without ever running it and does the same job; a layer can
be written that uses the annotation like a schema and checks the value at call
time. All three take this dictionary as raw material.

There is a second benefit, for the reader. The signature `def
describe_person(name, age)` does not say in what order the caller should give
what; `def describe_person(name: str, age: int) -> str` does. This is not a check,
it is a **document** — and being a document, it can also be wrong. There is no
mechanism ensuring the annotation and the body agree; what ensures agreement is
**another layer** reading the annotation.

The record itself is not protected either. `__annotations__` is an ordinary
dictionary: it can be modified just as it can be read. A running program can
delete a function's annotation or replace it with a different type; the
function's body is unaffected by this, and calls keep giving the same result.
This does not mean the record is unreliable — it means the record is **not
binding**. Bindingness is only born once a layer is added that reads the record
and makes a decision, and it is the rest of this course that builds that layer.

## Summary

- A type hint is the contract stating what type of value a name will carry; an
  annotation is that hint written into the code, and it can be written on a
  function signature, a class body, module level, and a function body.
- An annotation is stored in three places and not stored in one: a function gives
  **3** items, a class **2**, a module **1**; a local annotation inside a function
  body gives **0** and leaves no record behind.
- The interpreter stores the annotation, it does not check it; the annotation
  does not even have to be a type — a number written as a return annotation sits
  in the dictionary exactly as written, and the call runs.
- **4** of five calls are violations — **2** type violations, **2** domain
  violations — and the number that errors at run time is **0**. There is no layer
  in this measurement catching the violations.
- **Three** of four compound annotations cannot be given directly to an
  `isinstance` test; an annotation is not a check, it is a record, and the record
  is not binding.
- A type being correct does not mean a value is valid: an empty string is a
  string, **-5** is an integer. An annotation declares the type, not the value.

## Next Step

The `describe_person` function's annotation carries two concrete type names, and so
declares a contract that only works with those two types. Yet a great many
functions are indifferent to type: a function giving a list's first item does not
care what the list is filled with, but it does know that **what it returns is the
same type as what is inside the list**. This bond cannot be written with a
concrete type name — writing it either loses the bond or needlessly narrows the
function. The next lesson lays four separate notations of the same function side
by side and asks a single number: how many times can each notation's annotation
declare a call's return type?
