Write Clearer Functions with Python Type Hints
Annotate function inputs and return values and explain what annotations do and do not enforce.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Write Clearer Functions with Python Type Hints
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
We want to search a list of small records for a person's name without changing how the original name is written. If a record contains Ada Lovelace and the search uses lowercase ada lovelace, the function should return the original spelling. If no record matches, it should return None. Type hints let us state that contract in the function signature: the records are a list of dictionaries with string keys and values, the target is a string, and the result is either a string or None. Those annotations can improve editor suggestions, code review, and static analysis. They do not automatically validate arguments when Python runs the function.
- 00:41
We will prove that with a wrong-type call, inspect the annotations, and then implement the search with an explicit, testable rule. On PythonLessonLab the companion lesson includes a browser console, worked examples, a checked task, a quiz, and common mistakes. Predict the output before running each snippet. The goal is not to decorate every variable with a type; it is to make the important boundaries of a function understandable. A clear return type is especially useful here because a caller must handle the missing-name case. If the caller assumes the result is always a string, it may fail later in a less obvious place.
- 01:21
The annotation says target should be a string, but Python does not stop the call with the integer 42. The function starts running and tries to call the string method casefold on that integer. That produces AttributeError. Static type checkers can flag this call before execution if the code is analyzed and the types are known, but the Python interpreter generally treats annotations as metadata. This is a deliberate separation: a hint communicates intended use, while runtime validation is something the program must choose to perform. We could add `isinstance(target, str)` and raise a clear TypeError at the boundary if untrusted input reaches this function.
- 02:05
For a small lesson, our checked task assumes the documented input shape and focuses on the search behavior. Do not interpret the exception as evidence that hints are useless; the signature would have helped a reader or type checker catch the mistake earlier. Also notice that the return annotation allows None. That is not an error value being smuggled into a string type. It is part of the function's explicit contract, telling callers to handle both outcomes. A return annotation alone does not force the function to return the right kind of value either, so verification still matters. The return annotation is available as function metadata, and the actual calls produce two different values: a string when a word meets the threshold and None when nothing does.
- 02:54
The annotation has not changed the algorithm or inserted a runtime check. It has documented the branch that a caller must consider. In modern Python syntax, `str | None` is a concise way to say the result can be either type. Older code may spell a similar idea with Optional from the typing module. For the name search, we use the same pattern because an absent name is ordinary, not exceptional. The records annotation is `list[dict[str, str]]`. Read it from the outside in: a list of dictionaries, where the dictionary keys and values are strings. The implementation still needs to know which key to read, and a plain dictionary could be missing `name`.
- 03:37
If records come from a CSV file or external API, validate their shape at the boundary before trusting it. Hints are valuable precisely because they make such assumptions visible. Keep them consistent with behavior; a misleading hint can be worse than none. Do not claim that `list[dict[str, str]]` makes Python inspect every element at runtime. It does not. The target and each stored name are compared using casefold, which is designed for caseless string matching. We return the original name variable, not the folded comparison value. That preserves the spelling that was stored in the record.
- 04:18
The function returns as soon as it finds the first matching record, so if duplicate names exist, their input order decides which one is returned. A miss reaches the final return None. That behavior matches the type hint and gives the caller a clear branch to handle. A caller could write `match = find_name(people, query)` and then test `if match is None` before displaying it. This is preferable to testing the truthiness of the result if empty strings might be valid names in another domain. The website exercise asks you to complete this function with the same contract and run the task checker. Keep the function focused: do not print the name inside it and return None by accident.
- 05:02
Returning data lets another part of the program decide how to present it. We have not added runtime validation for malformed dictionaries or wrong argument types because those are separate requirements. When accepting untrusted inputs, add a validation layer and test its error messages explicitly. These checks test both sides of the annotated return type. An existing name returns the original spelling, including uppercase GRACE. A missing name returns None, and an empty record list does the same. When two records fold to the same text, the first record wins, so the query aDa returns Ada rather than the later lowercase entry.
- 05:45
That ordering rule is easy to miss unless we test it. The caller explicitly checks `result is None` before displaying a message; it does not call a string method on a missing value. On PythonLessonLab, complete the starter function, pass the practical task, and then answer the quiz. Try changing the return annotation to just `str` without changing the implementation. The code would still run, but the annotation would now lie about a possible None. Try returning a folded name instead and watch the original-casing check fail. This shows why a task checker and type hints complement each other. Hints communicate the contract; runnable tests check specific behavior.
- 06:29
For larger projects, a static type checker can inspect how callers use the nullable result, but it is still your responsibility to describe and test the domain rules. Type hints are most useful when they make those rules easier to reason about, not when they merely add punctuation to a function signature.
Understand the concept
Type hints describe the values a function expects and returns. In def greet(name: str) -> str, the first str describes the input and the second describes the return value. These hints make an API easier to read and help editors and static type checkers spot mistakes. Python itself does not reject a wrong argument solely because of an annotation. Keep validation or conversion when untrusted input needs runtime checking.
Modern Python uses built-in generic syntax such as list[str] and dict[str, int]. The brackets describe element types; they do not convert the elements. For a function returning nothing useful, write -> None. When a function may return either a value or None, write str | None and make the caller handle both cases. A default of None does not automatically make a str parameter nullable in the annotation.
A hint should match every branch of the function. If a search helper returns the first matching name or None, its return type is str | None, not str. One way to narrow the result for later code is an if result is None check. Hints on a list or dictionary also tell readers about the contained values, which is more useful than bare list or dict on a public function.
The exercise adds annotations to a small lookup function without changing its behavior. find_name records a list of name dictionaries and returns the matching name or None. Annotate both parameters and the return value, keep the case-insensitive comparison, and return None when no match exists. Tests inspect the annotations as well as matches, misses, and unchanged input data.
- Parameter and return annotations describe an interface
- list[T] and dict[K, V] describe collection contents
- T | None represents a value that may be absent
- Python does not enforce type hints at runtime
See it step by step
Read the code, predict the output, then compare it with the result.
01. Describe a simple function
def double(value: int) -> int:
return value * 2
print(double(4))
print(double.__annotations__)8
{'value': <class 'int'>, 'return': <class 'int'>}The annotations describe the parameter and return types and can be inspected without changing the function's arithmetic.
02. Annotate a collection and a nullable result
def first_long_word(words: list[str], minimum: int) -> str | None:
for word in words:
if len(word) >= minimum:
return word
return None
print(first_long_word(["ant", "python", "go"], 5))
print(first_long_word(["ant", "go"], 5))python
NoneThe list contains strings; the return annotation includes None because some searches fail.
03. Show that hints do not perform runtime validation
def repeat(word: str, times: int) -> str:
return word * times
print(repeat("ha", 2))
print(repeat(3, 2))haha
6Python executes the operations with the actual values. An annotation alone does not reject the integer passed as word.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Make a function contract visible
A small function can be easy to understand today and mysterious to another reader next month. Parameter annotations state the intended input values at the signature, and a return annotation states what the caller can expect. For a function adding integer scores, int on both parameters and on the result tells a reader what kind of values the arithmetic is meant to use. It also gives an editor or static checker information before the function runs.
Annotations are stored on the function and can be inspected with __annotations__. They are descriptive metadata, not a replacement for documentation about meaning or units. A function accepting a score could still need a sentence explaining its allowed range. Run this example and then replace one int hint with float; observe that the printed annotation changes while the calculation still works the same way.
def add_scores(first: int, second: int) -> int:
return first + second
print(add_scores(4, 7))
print(add_scores.__annotations__)11
{'first': <class 'int'>, 'second': <class 'int'>, 'return': <class 'int'>}- The parameter annotations describe two intended integer inputs.
- The arrow annotation describes the intended integer return value.
- __annotations__ exposes that contract to readers and tools.
Represent a collection and a missing result
A bare list annotation only says that some list is expected. list[str] says each element should be a string. That distinction matters in a search helper because the body calls string methods on each element. The helper below returns the original matching word, or None if no match exists. Its result therefore has two valid shapes, captured by str | None. The hint matches both branches of the function.
A caller must consider the absent case before using string operations on the result. The if found is None branch is both readable runtime behavior and something a static checker can use to narrow the type after the check. A default value of None on a parameter does not silently update an unrelated annotation; write a union explicitly when None is a supported value. Try an empty input list and verify that the missing branch still runs.
def find_word(words: list[str], target: str) -> str | None:
for word in words:
if word.casefold() == target.casefold():
return word
return None
found = find_word(["Ada", "Grace"], "ada")
missing = find_word(["Ada", "Grace"], "Lin")
print(found.upper() if found is not None else "not found")
print(missing.upper() if missing is not None else "not found")ADA
not found- list[str] describes the collection's elements, not just the container.
- str | None matches the successful and unsuccessful return branches.
- The caller checks for None before calling upper.
Separate static hints from runtime checks
Type hints do not cause Python to convert a string form field into an integer. A static checker may flag a suspicious call before execution, but the interpreter runs the code with whatever values were actually passed. If user input arrives as text, convert it explicitly and handle invalid text where the input enters the program. After conversion, the function can keep a clear integer-only contract and avoid mixing parsing with its calculation.
This example shows both stages without intentionally crashing the console. int(raw_age) turns valid numeric text into a number; the function then performs arithmetic. For real untrusted input, a try/except ValueError around int is appropriate. It is tempting to assume annotations make such checks redundant, but they do not. In the exercise, focus on adding precise hints to find_name while preserving its existing search behavior rather than adding a runtime type framework.
def next_age(age: int) -> int:
return age + 1
raw_age = "20"
age = int(raw_age)
print(next_age(age))
print(type(age).__name__)
print(next_age.__annotations__["age"].__name__)21
int
int- The incoming value is text even though the function expects an integer.
- int performs the runtime conversion before the function call.
- The annotation documents the contract; it did not perform the conversion.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Change only the signature to def find_name(records: list[dict[str, str]], target: str) -> str | None:; keep the loop and return None. Test that the annotations are visible in find_name.__annotations__.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Claiming a possibly absent result is always a string
def lookup(names: list[str], wanted: str) -> str:
return next((name for name in names if name == wanted), None)def lookup(names: list[str], wanted: str) -> str | None:
return next((name for name in names if name == wanted), None)What happens: The function can return None although its annotation promises str.
Include None in the return hint whenever a normal branch can return it.
Expecting an annotation to validate outside input
def age_next_year(age: int) -> int:
return age + 1
age_next_year('20')age = int('20')
print(age_next_year(age))What happens: A runtime TypeError occurs inside the function; the hint did not convert '20'.
Convert or validate user input explicitly before calling a function that expects an integer.
Using a bare container hint
def count_words(words: list) -> dict:def count_words(words: list[str]) -> dict[str, int]:What happens: Readers cannot tell what the list contains or what maps to each count.
Element, key, and value hints make the contract more useful.