Repository: konradhalas/dacite

Issue:
Possible bug: Cannot load dataclasses with members of defined generic type
I had a use case where some dataclasses had members with common fields.
In order to reduce code duplication, I tried to create a common class using generics.
When I tried to load my data - it failed with WrongTypeError, while the type received looks fine.

I wrote simple code example to replicate this behavior:

```py
from dataclasses import dataclass, asdict
from typing import Generic, TypeVar, List
from dacite import from_dict

T = TypeVar('T')
@dataclass
class Common(Generic[T]):
    foo: T
    bar: T


@dataclass
class A:
    elements: List[Common[int]]


a = A([Common[int](1, 2), Common[int](3, 4)])

from_dict(A, asdict(a))
```

Running the code throws the following exception:
`dacite.exceptions.WrongTypeError: wrong value type for field "elements" - should be "typing.List[__main__.Common[int]]" instead of value "[{'foo': 1, 'bar': 2}, {'foo': 3, 'bar': 4}]" of type "list"`

I believe this behavior to be a bug, since `[{'foo': 1, 'bar': 2}, {'foo': 3, 'bar': 4}]` is of type `List[Common[int]]`

_Note: this example is simplified, in my case I had another class that had a member of type `Common[str]`_


Discussion:

Patch:
diff --git a/.github/workflows/code_check.yaml b/.github/workflows/code_check.yaml
index fe5bb23..f110876 100644
--- a/.github/workflows/code_check.yaml
+++ b/.github/workflows/code_check.yaml
@@ -46,5 +46,5 @@ jobs:
           benchmark-data-dir-path: performance/${{ matrix.python-version }}
           comment-always: false
           alert-threshold: '130%'
-          comment-on-alert: false
-          fail-on-alert: true
+          comment-on-alert: true
+          fail-on-alert: false
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 1a133de..283c1d0 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -4,8 +4,8 @@ on:
     types: [published]
 
 jobs:
-  publish:
-    runs-on: ubuntu-22.04
+  build:
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
@@ -16,8 +16,29 @@ jobs:
         run: |
           python -m pip install --upgrade build
           python -m build
-      - name: Publish package to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
+      - name: Store the distribution packages
+        uses: actions/upload-artifact@v4
         with:
-          user: __token__
-          password: ${{ secrets.PYPI_API_TOKEN }}
+          name: python-package-distributions
+          path: dist/
+
+    publish-to-pypi:
+      name: Publish to PyPI
+      needs:
+        - build
+      runs-on: ubuntu-latest
+      environment:
+        name: pypi
+        url: https://pypi.org/p/dacite
+      permissions:
+        id-token: write  # IMPORTANT: mandatory for trusted publishing
+      steps:
+        - name: Download dist
+          uses: actions/download-artifact@v4
+          with:
+            name: python-package-distributions
+            path: dist/
+        - name: Publish package to PyPI
+          uses: pypa/gh-action-pypi-publish@release/v1
+          with:
+            password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 2278877..6ebb407 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ build/
 .idea
 
 venv*
+dacite-env*
 
 .benchmarks
 benchmark.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9ea14e..a719bb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+- [Support generics](https://github.com/konradhalas/dacite/pull/272)
+- Change type definition for `Data` in order to be more permissive
 - Fix issues with caching internal function calls
 
 ## [1.8.1] - 2023-05-12
diff --git a/README.md b/README.md
index da32ad7..08e4dc9 100644
--- a/README.md
+++ b/README.md
@@ -54,12 +54,14 @@ assert user == User(name='John', age=30, is_active=True)
 Dacite supports following features:
 
 - nested structures
-- (basic) types checking
+- (basic) type checking
 - optional fields (i.e. `typing.Optional`)
 - unions
+- generics
 - forward references
 - collections
 - custom type hooks
+- case conversion
 
 ## Motivation
 
@@ -109,6 +111,7 @@ Configuration is a (data) class with following fields:
 - `check_types`
 - `strict`
 - `strict_unions_match`
+- `convert_key`
 
 The examples below show all features of `from_dict` function and usage
 of all `Config` parameters.
@@ -233,6 +236,71 @@ result = from_dict(data_class=B, data=data)
 assert result == B(a_list=[A(x='test1', y=1), A(x='test2', y=2)])
 ```
 
+### Generics
+
+Dacite supports generics: (multi-)generic dataclasses, but also dataclasses that inherit from a generic dataclass, or dataclasses that have a generic dataclass field.
+
+```python
+T = TypeVar('T')
+U = TypeVar('U')
+
+@dataclass
+class X:
+    a: str
+
+
+@dataclass
+class A(Generic[T, U]):
+    x: T
+    y: list[U]
+
+data = {
+    'x': {
+        'a': 'foo',
+    },
+    'y': [1, 2, 3]
+}
+
+result = from_dict(data_class=A[X, int], data=data)
+
+assert result == A(x=X(a='foo'), y=[1,2,3])
+
+
+@dataclass
+class B(A[X, int]):
+    z: str
+
+data = {
+    'x': {
+        'a': 'foo',
+    },
+    'y': [1, 2, 3],
+    'z': 'bar'
+}
+
+result = from_dict(data_class=B, data=data)
+
+assert result == B(x=X(a='foo'), y=[1,2,3], z='bar')
+
+
+@dataclass
+class C:
+    z: A[X, int]
+
+data = {
+    'z': {
+        'x': {
+            'a': 'foo',
+        },
+        'y': [1, 2, 3],
+    }
+}
+
+result = from_dict(data_class=C, data=data)
+
+assert result == C(z=A(x=X(a='foo'), y=[1,2,3]))
+```
+
 ### Type hooks
 
 You can use `Config.type_hooks` argument if you want to transform the input 
@@ -313,30 +381,17 @@ data = from_dict(X, {"y": {"s": "text"}}, Config(forward_references={"Y": Y}))
 assert data == X(Y("text"))
 ```
 
-### Types checking
+### Type checking
 
-There are rare cases when `dacite` built-in type checker can not validate 
-your types (e.g. custom generic class) or you have such functionality 
-covered by other library and you don't want to validate your types twice. 
-In such case you can disable type checking with `Config(check_types=False)`.
-By default types checking is enabled.
+If you want to trade-off type checking for speed, you can disabled type checking by setting `check_types` to `False`.
 
 ```python
-T = TypeVar('T')
-
-
-class X(Generic[T]):
-    pass
-
-
 @dataclass
 class A:
-    x: X[str]
-
-
-x = X[str]()
+    x: str
 
-assert from_dict(A, {'x': x}, config=Config(check_types=False)) == A(x=x)
+# won't throw an error even though the type is wrong
+from_dict(A, {'x': 4}, config=Config(check_types=False)) 
 ```
 
 ### Strict mode
@@ -354,6 +409,30 @@ returns instance of this type. It means that it's possible that there are other
 matching types further on the `Union` types list. With `strict_unions_match` 
 only a single match is allowed, otherwise `dacite` raises `StrictUnionMatchError`.
 
+## Convert key
+
+You can pass a callable to the `convert_key` configuration parameter to convert camelCase to snake_case.
+
+```python
+def to_camel_case(key: str) -> str:
+    first_part, *remaining_parts = key.split('_')
+    return first_part + ''.join(part.title() for part in remaining_parts)
+
+@dataclass
+class Person:
+    first_name: str
+    last_name: str
+
+data = {
+    'firstName': 'John',
+    'lastName': 'Doe'
+}
+
+result = from_dict(Person, data, Config(convert_key=to_camel_case))
+
+assert result == Person(first_name='John', last_name='Doe') 
+```
+
 ## Exceptions
 
 Whenever something goes wrong, `from_dict` will raise adequate
@@ -392,33 +471,33 @@ first within an issue.
 
 Clone `dacite` repository:
 
-```
-$ git clone git@github.com:konradhalas/dacite.git
+```bash
+git clone git@github.com:konradhalas/dacite.git
 ```
 
 Create and activate virtualenv in the way you like:
 
-```
-$ python3 -m venv dacite-env
-$ source dacite-env/bin/activate
+```bash
+python3 -m venv dacite-env
+source dacite-env/bin/activate
 ```
 
 Install all `dacite` dependencies:
 
-```
-$ pip install -e .[dev]
+```bash
+pip install -e .[dev]
 ```
 
 And, optionally but recommended, install pre-commit hook for black:
 
-```
-$ pre-commit install
+```bash
+pre-commit install
 ```
 
 To run tests you just have to fire:
 
-```
-$ pytest
+```bash
+pytest
 ```
 
 ### Performance testing
diff --git a/dacite/config.py b/dacite/config.py
index 81e660b..78359da 100644
--- a/dacite/config.py
+++ b/dacite/config.py
@@ -19,6 +19,7 @@ class Config:
     check_types: bool = True
     strict: bool = False
     strict_unions_match: bool = False
+    convert_key: Callable[[str], str] = field(default_factory=lambda: lambda x: x)
 
     @cached_property
     def hashable_forward_references(self) -> Optional[FrozenDict]:
diff --git a/dacite/core.py b/dacite/core.py
index 9e45129..aa2a0b4 100644
--- a/dacite/core.py
+++ b/dacite/core.py
@@ -1,6 +1,6 @@
 from dataclasses import is_dataclass
 from itertools import zip_longest
-from typing import TypeVar, Type, Optional, get_type_hints, Mapping, Any, Collection, MutableMapping
+from typing import TypeVar, Type, Optional, Mapping, Any, Collection, MutableMapping
 
 from dacite.cache import cache
 from dacite.config import Config
@@ -8,7 +8,6 @@ from dacite.data import Data
 from dacite.dataclasses import (
     get_default_value_for_field,
     DefaultValueNotFoundError,
-    get_fields,
     is_frozen,
 )
 from dacite.exceptions import (
@@ -33,6 +32,8 @@ from dacite.types import (
     is_subclass,
 )
 
+from dacite.generics import get_concrete_type_hints, get_fields, orig
+
 T = TypeVar("T")
 
 
@@ -47,21 +48,26 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
     init_values: MutableMapping[str, Any] = {}
     post_init_values: MutableMapping[str, Any] = {}
     config = config or Config()
+
     try:
-        data_class_hints = cache(get_type_hints)(data_class, localns=config.hashable_forward_references)
+        data_class_hints = cache(get_concrete_type_hints)(data_class, localns=config.hashable_forward_references)
     except NameError as error:
         raise ForwardReferenceError(str(error))
+
     data_class_fields = cache(get_fields)(data_class)
+
     if config.strict:
         extra_fields = set(data.keys()) - {f.name for f in data_class_fields}
         if extra_fields:
             raise UnexpectedDataError(keys=extra_fields)
+
     for field in data_class_fields:
         field_type = data_class_hints[field.name]
-        if field.name in data:
+        key = config.convert_key(field.name)
+
+        if key in data:
             try:
-                field_data = data[field.name]
-                value = _build_value(type_=field_type, data=field_data, config=config)
+                value = _build_value(type_=field_type, data=data[key], config=config)
             except DaciteFieldError as error:
                 error.update_path(field.name)
                 raise
@@ -74,13 +80,17 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
                 if not field.init:
                     continue
                 raise MissingValueError(field.name)
+
         if field.init:
             init_values[field.name] = value
         elif not is_frozen(data_class):
             post_init_values[field.name] = value
+
     instance = data_class(**init_values)
+
     for key, value in post_init_values.items():
         setattr(instance, key, value)
+
     return instance
 
 
@@ -95,7 +105,7 @@ def _build_value(type_: Type, data: Any, config: Config) -> Any:
         data = _build_value_for_union(union=type_, data=data, config=config)
     elif is_generic_collection(type_):
         data = _build_value_for_collection(collection=type_, data=data, config=config)
-    elif cache(is_dataclass)(type_) and isinstance(data, Mapping):
+    elif cache(is_dataclass)(orig(type_)) and isinstance(data, Mapping):
         data = from_dict(data_class=type_, data=data, config=config)
     for cast_type in config.cast:
         if is_subclass(type_, cast_type):
diff --git a/dacite/data.py b/dacite/data.py
index c8e6ce4..6bde971 100644
--- a/dacite/data.py
+++ b/dacite/data.py
@@ -1,3 +1,14 @@
-from typing import Mapping, Any
+try:
+    from typing import Protocol  # type: ignore
+except ImportError:
+    from typing_extensions import Protocol  # type: ignore
 
-Data = Mapping[str, Any]
+
+# fmt: off
+class Data(Protocol):
+    def keys(self): ...
+
+    def __getitem__(self, item): ...
+
+    def __contains__(self, item): ...
+# fmt: on
diff --git a/dacite/generics.py b/dacite/generics.py
new file mode 100644
index 0000000..9a44c19
--- /dev/null
+++ b/dacite/generics.py
@@ -0,0 +1,99 @@
+import sys
+from dataclasses import Field, is_dataclass
+from typing import Any, Dict, Generic, List, Tuple, Type, TypeVar, Union, get_type_hints
+from dacite.exceptions import DaciteError
+
+try:
+    from typing import get_args, get_origin, Literal  # type: ignore
+except ImportError:
+    from typing_extensions import get_args, get_origin, Literal  # type: ignore
+
+from .dataclasses import get_fields as dataclasses_get_fields
+
+
+def __add_generics(type_origin: Any, type_args: Tuple, generics: Dict[TypeVar, Type]) -> None:
+    """Adds (type var, concrete type) entries derived from a type's origin and args to the provided generics dict."""
+    if type_origin and type_args and hasattr(type_origin, "__parameters__"):
+        for param, arg in zip(type_origin.__parameters__, type_args):
+            if isinstance(param, TypeVar):
+                if param in generics and generics[param] != arg:
+                    raise DaciteError(f"Ambiguous TypeVar: {generics[param]} != {arg}")
+                generics[param] = arg
+
+
+def __dereference(type_name: str, data_class: Type) -> Type:
+    """
+    Try to find the class belonging to the reference in the provided module and,
+    if not found, iteratively look in parent modules.
+    """
+    if data_class.__class__.__name__ == type_name:
+        return data_class
+
+    module_name = data_class.__module__
+    parts = module_name.split(".")
+    for i in range(len(parts)):
+        try:
+            module = sys.modules[".".join(parts[:-i]) if i else module_name]
+            return getattr(module, type_name)
+        except AttributeError:
+            pass
+    raise AttributeError("Could not find reference.")
+
+
+def __concretize(
+    hint: Union[Type, TypeVar, str], generics: Dict[TypeVar, Type], data_class: Type
+) -> Union[Type, TypeVar]:
+    """Recursively replace type vars and forward references by concrete types."""
+
+    if isinstance(hint, str):
+        return __dereference(hint, data_class)
+
+    if isinstance(hint, TypeVar):
+        # Fall back on the original TypeVar if the generics dict does not contain it.
+        # Setting config.check_types=False will in some cases still make from_dict work, albeit not type checked ofc.
+        return generics.get(hint, hint)
+
+    hint_origin = get_origin(hint)
+    hint_args = get_args(hint)
+    if hint_origin and hint_args and hint_origin is not Literal:
+        hint.__args__ = tuple(__concretize(a, generics, data_class) for a in hint_args)
+
+    return hint
+
+
+def orig(data_class: Type) -> Any:
+    if is_dataclass(data_class):
+        return data_class
+    return get_origin(data_class)
+
+
+def get_concrete_type_hints(data_class: Type, *args, **kwargs) -> Dict[str, Any]:
+    """
+    An overwrite of typing.get_type_hints supporting generics and forward references,
+    i.e. substituting concrete types in type vars and references.
+    """
+    generics: Dict[TypeVar, Type] = {}
+
+    dc_origin = get_origin(data_class)
+    dc_args = get_args(data_class)
+    __add_generics(dc_origin, dc_args, generics)
+
+    if hasattr(data_class, "__orig_bases__"):
+        for base in data_class.__orig_bases__:
+            base_origin = get_origin(base)
+            base_args = get_args(base)
+            if base_origin is not Generic:
+                __add_generics(base_origin, base_args, generics)
+
+    data_class = orig(data_class)
+    hints = get_type_hints(data_class, *args, **kwargs)
+
+    for key, hint in hints.copy().items():
+        hints[key] = __concretize(hint, generics, data_class)
+
+    return hints
+
+
+def get_fields(data_class: Type) -> List[Field]:
+    """An overwrite of dacite.dataclasses.get_fields supporting generics."""
+    return dataclasses_get_fields(orig(data_class))
diff --git a/dacite/types.py b/dacite/types.py
index af7b495..5cb1a5b 100644
--- a/dacite/types.py
+++ b/dacite/types.py
@@ -1,15 +1,10 @@
-from dataclasses import InitVar
-from typing import (
-    Type,
-    Any,
-    Optional,
-    Union,
-    Collection,
-    TypeVar,
-    Mapping,
-    Tuple,
-    cast as typing_cast,
-)
+from dataclasses import InitVar, is_dataclass
+from typing import Type, Any, Optional, Union, Collection, TypeVar, Mapping, Tuple, cast as typing_cast
+
+try:
+    from typing import get_origin  # type: ignore
+except ImportError:
+    from typing_extensions import get_origin  # type: ignore
 
 from dacite.cache import cache
 
@@ -94,52 +89,6 @@ def extract_init_var(type_: Type) -> Union[Type, Any]:
         return Any
 
 
-def is_instance(value: Any, type_: Type) -> bool:
-    try:
-        # As described in PEP 484 - section: "The numeric tower"
-        if (type_ in [float, complex] and isinstance(value, (int, float))) or isinstance(value, type_):
-            return True
-    except TypeError:
-        pass
-    if type_ == Any:
-        return True
-    elif is_union(type_):
-        return any(is_instance(value, t) for t in extract_generic(type_))
-    elif is_generic_collection(type_):
-        origin = extract_origin_collection(type_)
-        if not isinstance(value, origin):
-            return False
-        if not extract_generic(type_):
-            return True
-        if isinstance(value, tuple) and is_tuple(type_):
-            tuple_types = extract_generic(type_)
-            if len(tuple_types) == 1 and tuple_types[0] == ():
-                return len(value) == 0
-            elif len(tuple_types) == 2 and tuple_types[1] is ...:
-                return all(is_instance(item, tuple_types[0]) for item in value)
-            else:
-                if len(tuple_types) != len(value):
-                    return False
-                return all(is_instance(item, item_type) for item, item_type in zip(value, tuple_types))
-        if isinstance(value, Mapping):
-            key_type, val_type = extract_generic(type_, defaults=(Any, Any))
-            for key, val in value.items():
-                if not is_instance(key, key_type) or not is_instance(val, val_type):
-                    return False
-            return True
-        return all(is_instance(item, extract_generic(type_, defaults=(Any,))[0]) for item in value)
-    elif is_new_type(type_):
-        return is_instance(value, extract_new_type(type_))
-    elif is_literal(type_):
-        return value in extract_generic(type_)
-    elif is_init_var(type_):
-        return is_instance(value, extract_init_var(type_))
-    elif is_type_generic(type_):
-        return is_subclass(value, extract_generic(type_)[0])
-    else:
-        return False
-
-
 @cache
 def is_generic_collection(type_: Type) -> bool:
     if not is_generic(type_):
@@ -179,3 +128,54 @@ def is_type_generic(type_: Type) -> bool:
         return type_.__origin__ in (type, Type)
     except AttributeError:
         return False
+
+
+@cache
+def is_generic_dataclass(type_: Type) -> bool:
+    return is_dataclass(get_origin(type_))
+
+
+def is_instance(value: Any, type_: Type) -> bool:
+    try:
+        # As described in PEP 484 - section: "The numeric tower"
+        if (type_ in [float, complex] and isinstance(value, (int, float))) or isinstance(value, type_):
+            return True
+    except TypeError:
+        pass
+    if type_ == Any:
+        return True
+    if is_union(type_):
+        return any(is_instance(value, t) for t in extract_generic(type_))
+    if is_generic_collection(type_):
+        origin = extract_origin_collection(type_)
+        if not isinstance(value, origin):
+            return False
+        if not extract_generic(type_):
+            return True
+        if isinstance(value, tuple) and is_tuple(type_):
+            tuple_types = extract_generic(type_)
+            if len(tuple_types) == 1 and tuple_types[0] == ():
+                return len(value) == 0
+            if len(tuple_types) == 2 and tuple_types[1] is ...:
+                return all(is_instance(item, tuple_types[0]) for item in value)
+            if len(tuple_types) != len(value):
+                return False
+            return all(is_instance(item, item_type) for item, item_type in zip(value, tuple_types))
+        if isinstance(value, Mapping):
+            key_type, val_type = extract_generic(type_, defaults=(Any, Any))
+            for key, val in value.items():
+                if not is_instance(key, key_type) or not is_instance(val, val_type):
+                    return False
+            return True
+        return all(is_instance(item, extract_generic(type_, defaults=(Any,))[0]) for item in value)
+    if is_new_type(type_):
+        return is_instance(value, extract_new_type(type_))
+    if is_literal(type_):
+        return value in extract_generic(type_)
+    if is_init_var(type_):
+        return is_instance(value, extract_init_var(type_))
+    if is_type_generic(type_):
+        return is_subclass(value, extract_generic(type_)[0])
+    if is_generic_dataclass(type_):
+        return isinstance(value, get_origin(type_))  # type: ignore[arg-type]
+    return False
diff --git a/setup.py b/setup.py
index c89d17f..b550aa4 100644
--- a/setup.py
+++ b/setup.py
@@ -16,12 +16,13 @@ setup(
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
         "Programming Language :: Python",
-        "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
+        "Programming Language :: Python :: 3.13",
         "Topic :: Software Development :: Libraries :: Python Modules",
     ],
     python_requires=">=3.6",


Test patch:
diff --git a/tests/core/test_convert_key.py b/tests/core/test_convert_key.py
new file mode 100644
index 0000000..c818fe0
--- /dev/null
+++ b/tests/core/test_convert_key.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from dacite import Config, from_dict
+
+
+def test_convert_key():
+    def to_camel_case(key: str) -> str:
+        first_part, *remaining_parts = key.split("_")
+        return first_part + "".join(part.title() for part in remaining_parts)
+
+    @dataclass
+    class Person:
+        first_name: str
+        last_name: str
+
+    data = {"firstName": "John", "lastName": "Doe"}
+
+    result = from_dict(Person, data, Config(convert_key=to_camel_case))
+
+    assert result == Person(first_name="John", last_name="Doe")
diff --git a/tests/core/test_forward_reference.py b/tests/core/test_forward_reference.py
new file mode 100644
index 0000000..c891d35
--- /dev/null
+++ b/tests/core/test_forward_reference.py
@@ -0,0 +1,55 @@
+from dataclasses import dataclass
+from typing import Generic, List, Optional, TypeVar
+from dacite import from_dict
+
+T = TypeVar("T")
+
+
+@dataclass
+class Person:
+    name: str
+    children: Optional[List["Person"]] = None
+
+
+@dataclass
+class Club:
+    name: str
+    members: List["Person"]
+
+
+@dataclass
+class Employee:
+    name: str
+
+
+@dataclass
+class Team(Generic[T]):
+    name: str
+    members: Optional[List[T]] = None
+    subteams: Optional[List["Team[T]"]] = None
+
+
+def test_self_reference():
+    data = {"name": "John Doe", "children": [{"name": "Jane Doe"}]}
+
+    result = from_dict(Person, data)
+
+    assert result == Person(name="John Doe", children=[Person(name="Jane Doe")])
+
+
+def test_other_reference():
+    data = {"name": "FooBar", "members": [{"name": "John Doe", "children": [{"name": "Jane Doe"}]}]}
+
+    result = from_dict(Club, data)
+
+    assert result == Club(name="FooBar", members=[Person(name="John Doe", children=[Person(name="Jane Doe")])])
+
+
+def test_generic_self_reference():
+    data = {"name": "foo", "members": [{"name": "John"}], "subteams": [{"name": "bar", "members": [{"name": "Jane"}]}]}
+
+    result = from_dict(Team[Employee], data)
+
+    assert result == Team(
+        name="foo", members=[Employee(name="John")], subteams=[Team(name="bar", members=[Employee(name="Jane")])]
+    )
diff --git a/tests/core/test_generics.py b/tests/core/test_generics.py
new file mode 100644
index 0000000..eb38ea5
--- /dev/null
+++ b/tests/core/test_generics.py
@@ -0,0 +1,71 @@
+from dataclasses import dataclass
+from typing import Generic, List, TypeVar, get_type_hints
+from dacite import from_dict
+
+T = TypeVar("T")
+U = TypeVar("U")
+
+
+@dataclass
+class X:
+    a: str
+
+
+@dataclass
+class A(Generic[T, U]):
+    x: T
+    y: List[U]
+
+
+def test_multi_generic():
+    data = {
+        "x": {
+            "a": "foo",
+        },
+        "y": [1, 2, 3],
+    }
+
+    result = from_dict(data_class=A[X, int], data=data)
+
+    assert result == A(x=X(a="foo"), y=[1, 2, 3])
+
+    # assert that the typing object hasn't been modified in-place
+    hints = get_type_hints(A)
+    assert hints["x"].__class__ is TypeVar
+
+
+def test_generic_parent():
+    @dataclass
+    class B(A[X, int]):
+        z: str
+
+    data = {
+        "x": {
+            "a": "foo",
+        },
+        "y": [1, 2, 3],
+        "z": "bar",
+    }
+
+    result = from_dict(data_class=B, data=data)
+
+    assert result == B(x=X(a="foo"), y=[1, 2, 3], z="bar")
+
+
+def test_generic_field():
+    @dataclass
+    class C:
+        z: A[X, int]
+
+    data = {
+        "z": {
+            "x": {
+                "a": "foo",
+            },
+            "y": [1, 2, 3],
+        }
+    }
+
+    result = from_dict(data_class=C, data=data)
+
+    assert result == C(z=A(x=X(a="foo"), y=[1, 2, 3]))
diff --git a/tests/test_types.py b/tests/test_types.py
index c6b5664..63a3c5b 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -405,4 +405,8 @@ def test_extract_generic_special():
 @pytest.mark.skipif(version_info < (3, 10), reason="writing union types as X | Y requires Python 3.10")
 def test_optional_and_union_none_does_not_pollute_scope_via_caching():
     is_generic(Optional[str])
+
+
+@pep_604_support
+def test_optional_and_union_none_does_not_pollute_scope_via_caching_pep_604():
     is_generic_collection(str | None)