Skip to content
academia.sh

Lesson 08 / 11

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.

Contents

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.

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

"""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?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close