Skip to content

Commit 95f6dac

Browse files
committed
add python typing Literal Final and discriminator
1 parent f64b1e1 commit 95f6dac

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](https://mypy.readthedocs.io/en/stable/literal_types.html#tagged-unions) for [type narrowing](https://mypy.readthedocs.io/en/stable/type_narrowing.html).
660+
661+
```py title="Example with TypedDict"
662+
# https://mypy.readthedocs.io/en/stable/literal_types.html#tagged-unions
663+
from typing import Literal, TypedDict, Union
664+
665+
class NewJobEvent(TypedDict):
666+
tag: Literal["new-job"]
667+
job_name: str
668+
config_file_path: str
669+
670+
class CancelJobEvent(TypedDict):
671+
tag: Literal["cancel-job"]
672+
job_id: int
673+
674+
Event = Union[NewJobEvent, CancelJobEvent]
675+
676+
def process_event(event: Event) -> None:
677+
# Since we made sure both TypedDicts have a key named 'tag', it's
678+
# safe to do 'event["tag"]'. This expression normally has the type
679+
# Literal["new-job", "cancel-job"], but the check below will narrow
680+
# the type to either Literal["new-job"] or Literal["cancel-job"].
681+
#
682+
# This in turns narrows the type of 'event' to either NewJobEvent
683+
# or CancelJobEvent.
684+
if event["tag"] == "new-job":
685+
print(event["job_name"])
686+
else:
687+
print(event["job_id"])
688+
```
689+
690+
```py title="Example with generics"
691+
# https://mypy.readthedocs.io/en/stable/literal_types.html#tagged-unions
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)