Skip to content

Commit 505a9f8

Browse files
committed
add python typing Literal Final and discriminator
1 parent f64b1e1 commit 505a9f8

File tree

3 files changed

+134
-1
lines changed

3 files changed

+134
-1
lines changed

docs/posts/2025/2025-02-01-python-type-hints.md

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ categories:
77
comments: true
88
date:
99
created: 2025-02-01
10-
updated: 2025-08-17
10+
updated: 2025-08-20
1111
---
1212

1313
# Python Type Hints
@@ -18,6 +18,8 @@ Type hints ([PEP 484](https://peps.python.org/pep-0484/)) have been a major focu
1818

1919
Today, type hints are essential for modern Python development. They significantly enhance IDE capabilities and AI-powered development tools by providing better code completion, static analysis, and error detection. This mirrors the evolution we've seen with TypeScript's adoption over traditional JavaScript—explicit typing leads to more reliable and maintainable code.
2020

21+
The majority of this post is based on [MyPy documentation](https://mypy.readthedocs.io/).
22+
2123
!!! note "Typed Python vs data science projects"
2224
We know that type hints are [not very popular among data science projects](https://engineering.fb.com/2024/12/09/developer-tools/typed-python-2024-survey-meta/) for [some reasons](https://typing.python.org/en/latest/guides/typing_anti_pitch.html), but we won't discuss them here.
2325

@@ -613,6 +615,100 @@ class MyList[T](Sequence[T]):
613615
raise TypeError(...)
614616
```
615617

618+
## Literal and Final
619+
620+
`Literal` types may contain one or more literal `bool`, `int`, `str`, `bytes`, and `enum` values. Which means `Literal[3.14]` is not a valid literal type.
621+
622+
If you find repeating the value of the variable in the type hint to be tedious, you can instead change the variable to be `Final` (see Final names, methods and classes):
623+
624+
```python hl_lines="5 7"
625+
from typing import Final, Literal
626+
627+
def expects_literal(x: Literal[19]) -> None: pass
628+
629+
c: Final = 19
630+
631+
reveal_type(c) # Revealed type is "Literal[19]?"
632+
expects_literal(c) # ...and this type checks!
633+
```
634+
635+
Literals containing two or more values are equivalent to the union of those values. So, `Literal[-3, b"foo", MyEnum.A]` is equivalent to `Union[Literal[-3], Literal[b"foo"], Literal[MyEnum.A]]`. So we can has below code:
636+
637+
```python linenums="1" hl_lines="11"
638+
# https://mypy.readthedocs.io/en/stable/literal_types.html#parameterizing-literals
639+
from typing import Literal
640+
641+
PrimaryColors = Literal["red", "blue", "yellow"]
642+
SecondaryColors = Literal["purple", "green", "orange"]
643+
AllowedColors = Literal[PrimaryColors, SecondaryColors]
644+
645+
def paint(color: AllowedColors) -> None: ...
646+
647+
paint("red") # Type checks!
648+
paint("turquoise") # Does not type check
649+
```
650+
651+
```shell
652+
$ mypy docs/posts/2025/scripts/mypy_literal.py
653+
docs/posts/2025/scripts/mypy_literal.py:11: error: Argument 1 to "paint" has incompatible type "Literal['turquoise']"; expected "Literal['red', 'blue', 'yellow', 'purple', 'green', 'orange']" [arg-type]
654+
Found 1 error in 1 file (checked 1 source file)
655+
```
656+
657+
## Discriminated union types
658+
659+
We can use [Literals](#literal-and-final) to create discriminated union types (narrow types), allowing us to define a type that can be one of several specific values.
660+
661+
Ref: https://mypy.readthedocs.io/en/stable/literal_types.html#tagged-unions
662+
663+
```py title="Example with TypedDict"
664+
from typing import Literal, TypedDict, Union
665+
666+
class NewJobEvent(TypedDict):
667+
tag: Literal["new-job"]
668+
job_name: str
669+
config_file_path: str
670+
671+
class CancelJobEvent(TypedDict):
672+
tag: Literal["cancel-job"]
673+
job_id: int
674+
675+
Event = Union[NewJobEvent, CancelJobEvent]
676+
677+
def process_event(event: Event) -> None:
678+
# Since we made sure both TypedDicts have a key named 'tag', it's
679+
# safe to do 'event["tag"]'. This expression normally has the type
680+
# Literal["new-job", "cancel-job"], but the check below will narrow
681+
# the type to either Literal["new-job"] or Literal["cancel-job"].
682+
#
683+
# This in turns narrows the type of 'event' to either NewJobEvent
684+
# or CancelJobEvent.
685+
if event["tag"] == "new-job":
686+
print(event["job_name"])
687+
else:
688+
print(event["job_id"])
689+
```
690+
691+
```py title="Example with generics"
692+
class Wrapper[T]:
693+
def __init__(self, inner: T) -> None:
694+
self.inner = inner
695+
696+
def process(w: Wrapper[int] | Wrapper[str]) -> None:
697+
# Doing `if isinstance(w, Wrapper[int])` does not work: isinstance requires
698+
# that the second argument always be an *erased* type, with no generics.
699+
# This is because generics are a typing-only concept and do not exist at
700+
# runtime in a way `isinstance` can always check.
701+
#
702+
# However, we can side-step this by checking the type of `w.inner` to
703+
# narrow `w` itself:
704+
if isinstance(w.inner, int):
705+
reveal_type(w) # Revealed type is "Wrapper[int]"
706+
else:
707+
reveal_type(w) # Revealed type is "Wrapper[str]"
708+
```
709+
710+
And check [this Pydantic doc](https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions-with-str-discriminators) to see how Pydantic use `Field(discriminator='...')` to handle discriminators.
711+
616712
## Typing tools
617713

618714
### MyPy
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Literal, TypedDict, Union
2+
3+
4+
class NewJobEvent(TypedDict):
5+
tag: Literal["new-job"]
6+
job_name: str
7+
config_file_path: str
8+
9+
class CancelJobEvent(TypedDict):
10+
tag: Literal["cancel-job"]
11+
job_id: int
12+
13+
Event = Union[NewJobEvent, CancelJobEvent]
14+
15+
def process_event(event: Event) -> None:
16+
# Since we made sure both TypedDicts have a key named 'tag', it's
17+
# safe to do 'event["tag"]'. This expression normally has the type
18+
# Literal["new-job", "cancel-job"], but the check below will narrow
19+
# the type to either Literal["new-job"] or Literal["cancel-job"].
20+
#
21+
# This in turns narrows the type of 'event' to either NewJobEvent
22+
# or CancelJobEvent.
23+
if event["tag"] == "new-job":
24+
print(event["job_name"])
25+
else:
26+
print(event["job_id"])
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# https://mypy.readthedocs.io/en/stable/literal_types.html#parameterizing-literals
2+
from typing import Literal
3+
4+
PrimaryColors = Literal["red", "blue", "yellow"]
5+
SecondaryColors = Literal["purple", "green", "orange"]
6+
AllowedColors = Literal[PrimaryColors, SecondaryColors]
7+
8+
def paint(color: AllowedColors) -> None: ...
9+
10+
paint("red") # Type checks!
11+
paint("turquoise") # Does not type check

0 commit comments

Comments
 (0)