Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Happy coding with pyserde! 🚀
- `set`, `collections.abc.Set`, `collections.abc.MutableSet`
- `dict`, `collections.abc.Mapping`, `collections.abc.MutableMapping`
- [`frozenset`](https://docs.python.org/3/library/stdtypes.html#frozenset), [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict), [`deque`](https://docs.python.org/3/library/collections.html#collections.deque), [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter)
- [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict)
- [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)
- [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union)
- User defined class with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html)
Expand Down
46 changes: 46 additions & 0 deletions docs/en/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Here is the list of the supported types. See the simple example for each type in
* [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) [^4]
* [`deque`](https://docs.python.org/3/library/collections.html#collections.deque) [^25]
* [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) [^26]
* [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) [^27]
* [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional) [^5]
* [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) [^6] [^7] [^8]
* User defined class with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) [^9] [^10]
Expand Down Expand Up @@ -50,6 +51,49 @@ class Foo:
f: Bar
```

## TypedDict

[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) can be used as a field type in a `@serde` dataclass, or deserialized directly via `from_dict` / `from_json`.

```python
from typing import NotRequired, TypedDict
from serde import serde
from serde.json import to_json, from_json

class Movie(TypedDict):
title: str
year: int
director: str

class Person(TypedDict):
name: str
age: int
email: NotRequired[str] # optional field

@serde
class Cinema:
location: str
featured: Movie
```

```python
>>> movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"}
>>> cinema = Cinema(location="Downtown", featured=movie)

>>> to_json(cinema)
'{"location":"Downtown","featured":{"title":"Inception","year":2010,"director":"Christopher Nolan"}}'

>>> from_json(Cinema, to_json(cinema))
Cinema(location='Downtown', featured={'title': 'Inception', 'year': 2010, 'director': 'Christopher Nolan'})
```

`NotRequired` fields are omitted from the output when absent, and optional on input:

```python
>>> person_with_email: Person = {"name": "Alice", "age": 30, "email": "alice@example.com"}
>>> person_no_email: Person = {"name": "Bob", "age": 25}
```

## Numpy

All of the above (de)serialization methods can transparently handle most numpy types with the "numpy" extras package.
Expand Down Expand Up @@ -157,3 +201,5 @@ If you need to use a type which is currently not supported in the standard libra
[^25]: See [examples/deque.py](https://github.com/yukinarit/pyserde/blob/main/examples/deque.py)

[^26]: See [examples/counter.py](https://github.com/yukinarit/pyserde/blob/main/examples/counter.py)

[^27]: See [examples/typeddict.py](https://github.com/yukinarit/pyserde/blob/main/examples/typeddict.py)
46 changes: 46 additions & 0 deletions docs/ja/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) [^4]
* [`deque`](https://docs.python.org/3/library/collections.html#collections.deque) [^25]
* [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) [^26]
* [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) [^27]
* [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[^5]
* [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) [^6] [^7] [^8]
* [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) を用いたユーザ定義クラス [^9] [^10]
Expand Down Expand Up @@ -50,6 +51,49 @@ class Foo:
f: Bar
```

## TypedDict

[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) は `@serde` デコレータを付けたデータクラスのフィールド型として使用できます。また、`from_dict` / `from_json` で直接デシリアライズすることも可能です。

```python
from typing import NotRequired, TypedDict
from serde import serde
from serde.json import to_json, from_json

class Movie(TypedDict):
title: str
year: int
director: str

class Person(TypedDict):
name: str
age: int
email: NotRequired[str] # 省略可能なフィールド

@serde
class Cinema:
location: str
featured: Movie
```

```python
>>> movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"}
>>> cinema = Cinema(location="Downtown", featured=movie)

>>> to_json(cinema)
'{"location":"Downtown","featured":{"title":"Inception","year":2010,"director":"Christopher Nolan"}}'

>>> from_json(Cinema, to_json(cinema))
Cinema(location='Downtown', featured={'title': 'Inception', 'year': 2010, 'director': 'Christopher Nolan'})
```

`NotRequired` フィールドは値が存在しない場合に出力から省略され、入力でも省略できます。

```python
>>> person_with_email: Person = {"name": "Alice", "age": 30, "email": "alice@example.com"}
>>> person_no_email: Person = {"name": "Bob", "age": 25}
```

## Numpy

上記のすべての(デ)シリアライズ方法は、`numpy`追加パッケージを使用することで、ほとんどのnumpyデータ型を透過的に扱うことができます。
Expand Down Expand Up @@ -162,3 +206,5 @@ SQLAlchemy宣言的データクラスマッピング統合の実験的サポー
[^25]: [examples/deque.py](https://github.com/yukinarit/pyserde/blob/main/examples/deque.py) を参照

[^26]: [examples/counter.py](https://github.com/yukinarit/pyserde/blob/main/examples/counter.py) を参照

[^27]: [examples/typeddict.py](https://github.com/yukinarit/pyserde/blob/main/examples/typeddict.py) を参照
3 changes: 2 additions & 1 deletion examples/runner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import sys
import typing


if sys.version_info[:3] < (3, 12, 0):
print("examples require at least Python 3.12")
sys.exit(1)
Expand Down Expand Up @@ -72,6 +71,7 @@ def run_all() -> None:
import enum34
import kw_only
import self_type
import typeddict

run(any)
run(simple)
Expand Down Expand Up @@ -138,6 +138,7 @@ def run_all() -> None:
run(type_uuid)
run(type_numpy)
run(self_type)
run(typeddict)

try:
import type_sqlalchemy
Expand Down
1 change: 0 additions & 1 deletion examples/type_statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from serde import serde


type Baz = tuple[float, float]
type Bar = tuple[Baz, ...]

Expand Down
71 changes: 71 additions & 0 deletions examples/typeddict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
TypedDict example

TypedDict allows defining dict types with specific named keys and per-key types.
This is useful for JSON API responses where you want type safety without
full dataclass overhead.
"""

from typing import NotRequired, TypedDict

from serde import serde
from serde.json import from_json, to_json


# TypedDict with all required fields
class Movie(TypedDict):
title: str
year: int
director: str


# TypedDict with optional fields
class Person(TypedDict):
name: str
age: int
email: NotRequired[str] # Optional field


# Nested TypedDict
class Library(TypedDict):
name: str
movies: list[Movie]


# Using TypedDict as a field in a serde dataclass
@serde
class Cinema:
location: str
featured: Movie


def main() -> None:
# Create a Movie TypedDict
movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"}
print(f"Movie: {movie}")

# Create a Cinema with a Movie field
cinema = Cinema(location="Downtown", featured=movie)
print(f"Cinema: {cinema}")

# Serialize to JSON
json_str = to_json(cinema)
print(f"To JSON: {json_str}")

# Deserialize from JSON
restored = from_json(Cinema, json_str)
print(f"From JSON: {restored}")

# Verify the nested TypedDict
print(f"Featured movie title: {restored.featured['title']}")

# Test with optional fields
person: Person = {"name": "Alice", "age": 30} # email is optional
print(f"Person without email: {person}")

person_with_email: Person = {"name": "Bob", "age": 25, "email": "bob@example.com"}
print(f"Person with email: {person_with_email}")


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ select = [
ignore = ["B904"]

[tool.ruff.lint.mccabe]
max-complexity = 30
max-complexity = 34

[tool.ruff.lint.per-file-ignores]
# https://docs.kidger.site/jaxtyping/faq/#flake8-or-ruff-are-throwing-an-error
Expand Down
55 changes: 55 additions & 0 deletions serde/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,10 @@ def recursive(cls: Union[type[Any], Any]) -> None:
lst.add(tuple)
for arg in type_args(cls):
recursive(arg)
elif is_typeddict(cls):
lst.add(cls)
for _name, (field_type, _is_req) in typeddict_fields(cls).items():
recursive(field_type)
elif is_dict(cls):
lst.add(dict)
args = type_args(cls)
Expand Down Expand Up @@ -1073,6 +1077,57 @@ def is_pep695_type_alias(typ: Any) -> bool:
return isinstance(typ, _PEP695_TYPES)


def is_typeddict(typ: Any) -> bool:
"""
Test if the type is a TypedDict.

>>> from typing import TypedDict
>>> class Movie(TypedDict):
... title: str
... year: int
>>> is_typeddict(Movie)
True
>>> is_typeddict(dict)
False
"""
return typing_extensions.is_typeddict(typ)


def typeddict_fields(typ: type[Any]) -> dict[str, tuple[type[Any], bool]]:
"""
Get fields from a TypedDict with their types and required status.

Returns a dict mapping field name to (unwrapped_type, is_required).

>>> from typing import TypedDict, NotRequired
>>> class Movie(TypedDict):
... title: str
... year: NotRequired[int]
>>> fields = typeddict_fields(Movie)
>>> fields['title']
(<class 'str'>, True)
>>> fields['year']
(<class 'int'>, False)
"""
hints = typing.get_type_hints(typ, include_extras=True)
required_keys = frozenset(getattr(typ, "__required_keys__", hints.keys()))

result: dict[str, tuple[type[Any], bool]] = {}
for name, field_type in hints.items():
origin = get_origin(field_type)
# Unwrap NotRequired[T] or Required[T] wrappers
if origin is typing_extensions.NotRequired:
inner = type_args(field_type)[0]
result[name] = (inner, False)
elif origin is typing_extensions.Required:
inner = type_args(field_type)[0]
result[name] = (inner, True)
else:
result[name] = (field_type, name in required_keys)

return result


@cache
def get_type_var_names(cls: type[Any]) -> list[str] | None:
"""
Expand Down
18 changes: 17 additions & 1 deletion serde/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@
is_opt_dataclass,
is_set,
is_tuple,
is_typeddict,
is_union,
is_variable_tuple,
type_args,
typeddict_fields,
typename,
_WithTagging,
)
Expand Down Expand Up @@ -368,7 +370,9 @@ def is_instance(obj: Any, typ: Any) -> bool:
pyserde's own `isinstance` helper. It accepts subscripted generics e.g. `list[int]` and
deeply check object against declared type.
"""
if dataclasses.is_dataclass(typ):
if is_typeddict(typ):
return is_typeddict_instance(obj, typ)
elif dataclasses.is_dataclass(typ):
if not isinstance(typ, type):
raise SerdeError("expect dataclass class but dataclass instance received")
return isinstance(obj, typ)
Expand Down Expand Up @@ -421,6 +425,18 @@ def is_union_instance(obj: Any, typ: type[Any]) -> bool:
return False


def is_typeddict_instance(obj: Any, typ: type[Any]) -> bool:
if not isinstance(obj, dict):
return False
td_fields = typeddict_fields(typ)
for name, (field_type, is_required) in td_fields.items():
if is_required and name not in obj:
return False
if name in obj and not is_instance(obj[name], field_type):
return False
return True


def is_list_instance(obj: Any, typ: type[Any]) -> bool:
origin = get_origin(typ) or typ
if origin is list:
Expand Down
Loading
Loading