Repository: litestar-org/polyfactory
Pull request #711 (2025-06-05 08:21:38)

feat: add PEP 695 type alias support and improve type handling
## Summary
This PR adds full support for type aliases from PEP 695 (Python 3.12+) and improves overall type handling in polyfactory.

### Key Changes
#### 1. `normalize_type` function
Added a new `normalize_type` function that serves as a compatibility layer for handling different type representations across Python versions. This adapter:

- Problem it solves: [PEP 695](https://peps.python.org/pep-0695/) introduces TypeAliasType, which requires special handling to extract the underlying type value. Polyfactory couldn't process this new type during data generation, which led to errors:
```python
polyfactory.exceptions.ParameterException: Unsupported type: ExampleTypeAliasType on field 'example_field' from class FooFactory.
Either use 'add_provider', extend the providers map, or add a factory function for the field on the model.
```
The adapter also correctly handles nested scenarios such as `list[ExampleTypeAliasType]`, resolves generic `TypeAliasType`, and cases where other `TypeAliasType` instances are used as generic type parameters.

Additionally, I've refactored type checking logic by introducing two utility methods:
- `is_generic_alias()`: Checks if a type is a generic alias
- `is_type_var()`: Checks if a type is a `TypeVar`

All instances of `isinstance(value, TypeVar)` have been replaced with is_type_var(value) for consistency. Comprehensive tests have been added to ensure the adapter works correctly with various type scenarios.

#### 2. Rationale for the Chosen Approach
The type normalization in `PydanticFieldMeta.from_field_info` is implemented as:
```python
field_info = FieldInfo.merge_field_infos(
    field_info, 
    FieldInfo.from_annotation(normalize_type(field_info.annotation))
)
```

This approach provides several benefits:
- **Clean implementation:** Using normalization without conditional checks like is_type_alias improves code readability and eliminates the need for redundant tests that verify conditional behavior for each type scenario
- **Metadata preservation:** Using `merge_field_infos` ensures that metadata like example values aren't lost during the annotation transformation

### Testing

- Added extensive test coverage for PEP 695 type aliases including:
    - Basic type aliases
    - Generic type aliases with single and multiple type parameters
    - Nested generic structures
    - Annotated types with constraints
    - Union types and optional types
    - Complex nested combinations
- Added tests for the `normalize_type` function
- Added tests have been written to check support PEP 695 for `DataclassFactory`

Fixes #707

github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/711
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/711

Patch:
diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py
index 5446032..d91fd5e 100644
--- a/polyfactory/factories/base.py
+++ b/polyfactory/factories/base.py
@@ -65,7 +65,14 @@ from polyfactory.utils.helpers import (
     unwrap_optional,
 )
 from polyfactory.utils.model_coverage import CoverageContainer, CoverageContainerCallable, resolve_kwargs_coverage
-from polyfactory.utils.predicates import get_type_origin, is_literal, is_optional, is_safe_subclass, is_union
+from polyfactory.utils.predicates import (
+    get_type_origin,
+    is_literal,
+    is_optional,
+    is_safe_subclass,
+    is_type_var,
+    is_union,
+)
 from polyfactory.utils.types import NoneType
 from polyfactory.value_generators.complex_types import handle_collection_type, handle_collection_type_coverage
 from polyfactory.value_generators.constrained_collections import (
@@ -280,7 +287,7 @@ class BaseFactory(ABC, Generic[T]):
             b for b in get_original_bases(cls) if get_origin(b) and issubclass(get_origin(b), BaseFactory)
         )
         generic_args: Sequence[type[T]] = [
-            arg for factory_base in factory_bases for arg in get_args(factory_base) if not isinstance(arg, TypeVar)
+            arg for factory_base in factory_bases for arg in get_args(factory_base) if not is_type_var(arg)
         ]
         if len(generic_args) != 1:
             return None
@@ -851,7 +858,7 @@ class BaseFactory(ABC, Generic[T]):
         if provider := (provider_map.get(field_meta.annotation) or provider_map.get(unwrapped_annotation)):
             return provider()
 
-        if isinstance(unwrapped_annotation, TypeVar):
+        if is_type_var(unwrapped_annotation):
             return create_random_string(cls.__random__, min_length=1, max_length=10)
 
         if callable(unwrapped_annotation):
@@ -933,7 +940,7 @@ class BaseFactory(ABC, Generic[T]):
             ):
                 yield CoverageContainerCallable(provider)
 
-            elif isinstance(unwrapped_annotation, TypeVar):
+            elif is_type_var(unwrapped_annotation):
                 yield create_random_string(cls.__random__, min_length=1, max_length=10)
 
             elif callable(unwrapped_annotation):
diff --git a/polyfactory/factories/pydantic_factory.py b/polyfactory/factories/pydantic_factory.py
index 72d54d3..5f6f186 100644
--- a/polyfactory/factories/pydantic_factory.py
+++ b/polyfactory/factories/pydantic_factory.py
@@ -16,6 +16,7 @@ from polyfactory.factories.base import BuildContext as BaseBuildContext
 from polyfactory.field_meta import Constraints, FieldMeta, Null
 from polyfactory.utils.deprecation import check_for_deprecated_parameters
 from polyfactory.utils.helpers import unwrap_new_type, unwrap_optional
+from polyfactory.utils.normalize_type import normalize_type
 from polyfactory.utils.predicates import is_annotated, is_optional, is_safe_subclass, is_union
 from polyfactory.utils.types import NoneType
 from polyfactory.value_generators.primitives import create_random_bytes
@@ -167,6 +168,10 @@ class PydanticFieldMeta(FieldMeta):
                 ("random", random),
             ),
         )
+        field_info = FieldInfo.merge_field_infos(
+            field_info, FieldInfo.from_annotation(normalize_type(field_info.annotation))
+        )
+
         if callable(field_info.default_factory):
             default_value = field_info.default_factory
         else:
@@ -220,7 +225,7 @@ class PydanticFieldMeta(FieldMeta):
             if is_json:
                 constraints["json"] = True
 
-        result = PydanticFieldMeta.from_type(
+        result = super().from_type(
             annotation=annotation,
             children=children,
             constraints=cast("Constraints", {k: v for k, v in constraints.items() if v is not None}) or None,
diff --git a/polyfactory/field_meta.py b/polyfactory/field_meta.py
index 7e35521..ae154b4 100644
--- a/polyfactory/field_meta.py
+++ b/polyfactory/field_meta.py
@@ -9,6 +9,7 @@ from typing_extensions import get_args, get_origin
 from polyfactory.constants import DEFAULT_RANDOM, TYPE_MAPPING
 from polyfactory.utils.deprecation import check_for_deprecated_parameters
 from polyfactory.utils.helpers import get_annotation_metadata, is_dataclass_instance, unwrap_annotated, unwrap_new_type
+from polyfactory.utils.normalize_type import normalize_type
 from polyfactory.utils.predicates import is_annotated
 from polyfactory.utils.types import NoneType
 
@@ -139,6 +140,7 @@ class FieldMeta:
             ),
         )
 
+        annotation = normalize_type(annotation)
         annotated = is_annotated(annotation)
         if not constraints and annotated:
             metadata = cls.get_constraints_metadata(annotation)
diff --git a/polyfactory/utils/normalize_type.py b/polyfactory/utils/normalize_type.py
new file mode 100644
index 0000000..fe737f2
--- /dev/null
+++ b/polyfactory/utils/normalize_type.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from typing import Any, Mapping, Union
+
+from typing_extensions import Annotated, get_args, get_origin
+
+from polyfactory.utils.predicates import (
+    is_generic_alias,
+    is_type_alias,
+    is_type_var,
+    is_union,
+)
+
+
+def normalize_type(type_annotation: Any) -> Any:
+    """Convert modern Python 3.12+ type syntax to standard annotations.
+
+    Handles TypeAliasType and GenericAlias types introduced in Python 3.12+,
+    converting them to standard type annotations when needed.
+
+    Args:
+        type_annotation: Type to normalize (convert if needed or pass through).
+
+    Returns:
+        Normalized type annotation with resolved type aliases and substituted parameters.
+
+    Example:
+        ```python
+        # Python 3.12+
+        >> from typing import Annotated
+        >> import annotated_types as at
+
+        >> type NegativeInt = Annotated[int, at.Lt(0)]
+        >> type NonEmptyList[T] = Annotated[list[T], at.Len(1)]
+
+        >> normalize_type(NonEmptyList[NegativeInt])
+        # typing.Annotated[list[typing.Annotated[int, Lt(lt=0)]], Len(min_length=1, max_length=None)]
+        ```
+    """
+
+    if is_type_alias(type_annotation):
+        return type_annotation.__value__
+
+    if not is_generic_alias(type_annotation):
+        return type_annotation
+
+    origin = get_origin(type_annotation)
+    args = get_args(type_annotation)
+
+    if is_type_alias(origin):
+        return __handle_generic_type_alias(origin, args)
+
+    if args:
+        normalized_args = tuple(normalize_type(arg) for arg in args)
+        if normalized_args != args:
+            return origin[normalized_args[0] if len(normalized_args) == 1 else normalized_args]
+
+    return type_annotation
+
+
+def __handle_generic_type_alias(origin: Any, args: tuple) -> Any:
+    """Handle generic type alias with parameters."""
+    template = origin.__value__
+    type_params = origin.__type_params__
+
+    if not (type_params and args):
+        return template
+
+    normalized_args = tuple(normalize_type(arg) for arg in args)
+    substitutions = dict(zip(type_params, normalized_args))
+
+    if get_origin(template) is Annotated:
+        base_type, *metadata = get_args(template)
+        template_result = Annotated[tuple([__apply_substitutions(base_type, substitutions)] + metadata)]  # type: ignore[valid-type]
+    else:
+        template_result = __apply_substitutions(template, substitutions)
+
+    return template_result
+
+
+def __apply_substitutions(target: Any, subs: Mapping[Any, Any]) -> Any:
+    if is_type_var(target):
+        return subs.get(target, target)
+
+    if is_union(target):
+        args = tuple(__apply_substitutions(arg, subs) for arg in get_args(target))
+        return Union[args]
+
+    origin = get_origin(target)
+    args = get_args(target)
+
+    if is_type_alias(origin):
+        sub_args = tuple(__apply_substitutions(arg, subs) for arg in args) if args else ()
+        return normalize_type(origin[sub_args] if sub_args else origin)
+
+    if origin and args:
+        sub_args = tuple(__apply_substitutions(arg, subs) for arg in args)
+        return origin[sub_args[0] if len(sub_args) == 1 else sub_args]
+
+    return target
diff --git a/polyfactory/utils/predicates.py b/polyfactory/utils/predicates.py
index 9840081..b53bb30 100644
--- a/polyfactory/utils/predicates.py
+++ b/polyfactory/utils/predicates.py
@@ -147,6 +147,28 @@ def is_type_alias(annotation: Any) -> TypeGuard[TypeAliasType]:
     return isinstance(annotation, AllTypeAliasTypes)
 
 
+def is_generic_alias(annotation: Any) -> bool:
+    """Determine if the given type annotation is a generic alias.
+
+    :param annotation: A type annotation.
+
+    :returns: A boolean
+    """
+    return hasattr(annotation, "__origin__") and hasattr(annotation, "__args__")
+
+
+def is_type_var(annotation: Any) -> TypeGuard[TypeVar]:
+    """Determine if the given type annotation is a TypeVar.
+
+    Args:
+        annotation: A type annotation.
+
+    Returns:
+        A boolean.
+    """
+    return isinstance(annotation, TypeVar)
+
+
 def get_type_origin(annotation: Any) -> Any:
     """Get the type origin of an annotation - safely.
 


Test patch:
diff --git a/tests/test_dataclass_factory.py b/tests/test_dataclass_factory.py
index 158c3f9..23dd129 100644
--- a/tests/test_dataclass_factory.py
+++ b/tests/test_dataclass_factory.py
@@ -1,4 +1,6 @@
 import dataclasses
+import sys
+import textwrap
 from dataclasses import dataclass as vanilla_dataclass
 from dataclasses import field
 from types import ModuleType
@@ -351,3 +353,23 @@ def test_optional_type(allow_none: bool) -> None:
 
         if isinstance(a.c, list):
             assert all(isinstance(value, int) for value in a.c)
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+def test_pep695_dict_union_types(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type aliases with dict unions."""
+    module = create_module(
+        textwrap.dedent("""
+            from dataclasses import dataclass
+
+            type IntDict = dict[str, int]
+            type StrDict = dict[str, str]
+            type MixedDict = IntDict | StrDict
+
+            @dataclass
+            class Foo:
+                data: MixedDict
+                nested: dict[str, MixedDict]
+        """)
+    )
+    DataclassFactory.create_factory(module.Foo).build()
diff --git a/tests/test_pydantic_factory.py b/tests/test_pydantic_factory.py
index 129ff5f..2b2bfd9 100644
--- a/tests/test_pydantic_factory.py
+++ b/tests/test_pydantic_factory.py
@@ -1,11 +1,13 @@
 import re
 import sys
+import textwrap
 from collections import Counter, deque
 from dataclasses import dataclass
 from datetime import date, datetime, time, timedelta
 from decimal import Decimal
 from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
 from pathlib import Path
+from types import ModuleType
 from typing import Any, Callable, Dict, FrozenSet, List, Literal, Optional, Sequence, Set, Tuple, Type, Union
 from uuid import UUID
 
@@ -1160,3 +1162,297 @@ def test_rebuild() -> None:
         pass
 
     assert isinstance(BaseFactory.build(), Base)
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_basic_type_alias(create_module: Callable[[str], ModuleType]) -> None:
+    """Test basic type alias without generics."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type UserId = int
+            type Username = str
+
+            class Foo(BaseModel):
+                id: UserId
+                name: Username
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_generic_type_alias(create_module: Callable[[str], ModuleType]) -> None:
+    """Test generic type alias with single type parameter."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type Container[T] = list[T] | tuple[T]
+
+            class Foo(BaseModel):
+                strings: Container[str]
+                numbers: Container[int]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_nested_generic_type_alias(create_module: Callable[[str], ModuleType]) -> None:
+    """Test nested generic type aliases."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type Inner[T] = list[T]
+            type Outer[T] = Inner[Inner[T]]
+
+            class Foo(BaseModel):
+                nested: Outer[int]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_annotated_type_alias(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias with Annotated types."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import Gt, MaxLen
+            from pydantic import BaseModel
+
+            type PositiveInt = Annotated[int, Gt(0)]
+            type ShortStr = Annotated[str, MaxLen(5)]
+
+            class Foo(BaseModel):
+                age: PositiveInt
+                code: ShortStr
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_union_of_annotated_types(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias that is a union of annotated types."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import Le, Ge
+            from pydantic import BaseModel
+
+            type SmallInt = Annotated[int, Le(10)]
+            type LargeInt = Annotated[int, Ge(100)]
+            type ExtremeInt = SmallInt | LargeInt
+
+            class Foo(BaseModel):
+                value: ExtremeInt
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_recursive_annotation_field(create_module: Callable[[str], ModuleType]) -> None:
+    """Test the original recursive annotation case."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import Lt
+            from pydantic import BaseModel
+
+            type NegativeInt = Annotated[int, Lt(0)]
+            type NonEmptyIterable[T] = list[T] | tuple[T]
+
+            class Foo(BaseModel):
+                field: NonEmptyIterable[NonEmptyIterable[NegativeInt]]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_complex_nested_unions(create_module: Callable[[str], ModuleType]) -> None:
+    """Test complex nested unions with constraints."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import MinLen
+            from pydantic import BaseModel
+
+            type NumStr = int | str
+            type Container[T] = Annotated[list[T], MinLen(1)] | dict[str, T]
+
+            class Foo(BaseModel):
+                data: Container[Container[NumStr]]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_multiple_type_parameters(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias with multiple type parameters."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type Pair[T, U] = tuple[T, U] | list[T | U]
+
+            class Foo(BaseModel):
+                int_str_pair: Pair[int, str]
+                float_bool_pair: Pair[float, bool]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_with_pydantic_field(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias with Pydantic Field constraints."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import Ge, Le
+            from pydantic import BaseModel, Field
+
+            type Score = Annotated[int, Ge(0), Le(100)]
+
+            class Foo(BaseModel):
+                test_score: Score = Field(description="Test score between 0 and 100")
+                final_score: Score = Field(default=75)
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_optional_types(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias with optional types."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type MaybeInt = int | None
+            type OptionalContainer[T] = list[T] | None
+
+            class Foo(BaseModel):
+                maybe_number: MaybeInt
+                maybe_strings: OptionalContainer[str]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_annotated_union_distribution(create_module: Callable[[str], ModuleType]) -> None:
+    """Test that Annotated[Union[...], constraint] distributes constraints correctly."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import MinLen
+            from pydantic import BaseModel
+
+            type ConstrainedUnion = Annotated[list[int] | dict[str, int], MinLen(2)]
+
+            class Foo(BaseModel):
+                data: ConstrainedUnion
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_deeply_nested_structure(create_module: Callable[[str], ModuleType]) -> None:
+    """Test deeply nested type aliases."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type Level1[T] = list[T]
+            type Level2[T] = Level1[Level1[T]]
+            type Level3[T] = dict[str, Level2[T]] | Level2[T]
+
+            class Foo(BaseModel):
+                deep_data: Level3[int]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_with_decimal_constraints(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type alias with decimal constraints."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from decimal import Decimal
+            from annotated_types import Ge, MultipleOf
+            from pydantic import BaseModel
+
+            type Price = Annotated[Decimal, Ge(0), MultipleOf(Decimal("0.01"))]
+
+            class Foo(BaseModel):
+                amount: Price
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_with_nested_constraints(create_module: Callable[[str], ModuleType]) -> None:
+    """Test nested type aliases with various constraint combinations."""
+    module = create_module(
+        textwrap.dedent("""
+            from typing import Annotated
+            from annotated_types import Gt, MaxLen
+            from pydantic import BaseModel
+
+            type PositiveInt = Annotated[int, Gt(0)]
+            type SmallList[T] = Annotated[list[T], MaxLen(5)]
+            type Container[T] = SmallList[T] | tuple[T, ...]
+
+            class Foo(BaseModel):
+                numbers: Container[PositiveInt]
+                nested: SmallList[Container[int]]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="only for Pydantic v2")
+def test_pep695_dict_union_types(create_module: Callable[[str], ModuleType]) -> None:
+    """Test type aliases with dict unions."""
+    module = create_module(
+        textwrap.dedent("""
+            from pydantic import BaseModel
+
+            type IntDict = dict[str, int]
+            type StrDict = dict[str, str]
+            type MixedDict = IntDict | StrDict
+
+            class Foo(BaseModel):
+                data: MixedDict
+                nested: dict[str, MixedDict]
+        """)
+    )
+    ModelFactory.create_factory(module.Foo).build()
diff --git a/tests/utils/test_normalize_type.py b/tests/utils/test_normalize_type.py
new file mode 100644
index 0000000..296db37
--- /dev/null
+++ b/tests/utils/test_normalize_type.py
@@ -0,0 +1,155 @@
+import sys
+import textwrap
+from types import ModuleType
+from typing import Any, Callable, Dict, List, Set, Tuple, get_args, get_origin
+
+import annotated_types as at
+import pytest
+from typing_extensions import Annotated
+
+from polyfactory.utils.normalize_type import normalize_type
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12+")
+class TestNormalizeTypePep695:
+    @staticmethod
+    def assert_annotated_type(result: Any, expected_base: type, expected_metadata_type: type) -> None:
+        """Assert that result is an Annotated type with expected base and metadata types."""
+        assert get_origin(result) is Annotated
+        args = get_args(result)
+        assert args[0] == expected_base
+        assert isinstance(args[1], expected_metadata_type)
+
+    def test_simple_type_alias(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of simple type alias."""
+
+        module = create_module(
+            textwrap.dedent(
+                """
+                type SimpleInt = int
+                """
+            )
+        )
+        result = normalize_type(module.SimpleInt)
+
+        assert result is int
+
+    def test_annotated_type_alias(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of annotated type alias."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                from typing import Annotated
+                from annotated_types import Gt
+
+                type PositiveInt = Annotated[int, Gt(0)]
+                """
+            )
+        )
+        result = normalize_type(module.PositiveInt)
+        self.assert_annotated_type(result, int, at.Gt)
+
+    def test_generic_type_alias(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of generic type alias with type parameter."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                type GenericList[T] = list[T]
+                """
+            )
+        )
+
+        result = normalize_type(module.GenericList[str])
+
+        assert result == list[str]
+
+    def test_annotated_generic_type_alias(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of annotated generic type alias."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                from typing import Annotated
+                from annotated_types import Len
+
+                type NonEmptyList[T] = Annotated[list[T], Len(1)]
+                """
+            )
+        )
+        result = normalize_type(module.NonEmptyList[int])
+
+        self.assert_annotated_type(result, list[int], at.Len)
+
+    def test_nested_type_aliases(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of nested type aliases."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                from typing import Annotated
+                from annotated_types import Lt, Len
+
+                type NegativeInt = Annotated[int, Lt(0)]
+                type NonEmptyList[T] = Annotated[list[T], Len(1)]
+                """
+            )
+        )
+        result = normalize_type(module.NonEmptyList[module.NegativeInt])
+
+        self.assert_annotated_type(result, list[Annotated[int, at.Lt(0)]], at.Len)
+
+    def test_double_nested_type_aliases(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test resolution of double nested type aliases."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                from typing import Annotated
+                from annotated_types import Len
+
+                type NonEmptyList[T] = Annotated[list[T], Len(1)]
+                """
+            )
+        )
+
+        result = normalize_type(module.NonEmptyList[module.NonEmptyList[int]])
+
+        assert get_origin(result) is Annotated
+        outer_list, _ = get_args(result)
+
+        assert get_origin(outer_list) is list
+        inner_annotated = get_args(outer_list)[0]
+
+        assert get_origin(inner_annotated) is Annotated
+        inner_list, _ = get_args(inner_annotated)
+        assert inner_list == list[int]
+
+    def test_nested_generic_type_aliases(self, create_module: Callable[[str], ModuleType]) -> None:
+        """Test nested generic type aliases."""
+        module = create_module(
+            textwrap.dedent(
+                """
+                type Inner[T] = list[T]
+                type Outer[T] = Inner[Inner[T]]
+                """
+            )
+        )
+
+        result = normalize_type(module.Outer[int])
+        assert result == list[list[int]]
+
+
+@pytest.mark.parametrize(
+    "type_hint,expected",
+    [
+        (int, int),
+        (str, str),
+        (List[int], List[int]),
+        (Dict[str, int], Dict[str, int]),
+        (Tuple[int, ...], Tuple[int, ...]),
+        (Set[str], Set[str]),
+    ],
+    ids=["int", "str", "list", "dict", "tuple", "set"],
+)
+def test_builtin_types_unchanged(type_hint: type, expected: type) -> None:
+    """Test that built-in types without alias are returned unchanged."""
+    result = normalize_type(type_hint)
+
+    assert result == expected