Repository: pydantic/pydantic
Pull request: Allow callable discriminator to be applied on PEP 695 type aliases
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

Fixes https://github.com/pydantic/pydantic/issues/11930.

<!-- Please give a short summary of the changes. -->

## Related issue number

<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [ ] Unit tests for the changes exist
* [ ] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Problem statement:
Tag not extracted from Annotated when using nested named type aliases.
### Initial Checks

- [x] I confirm that I'm using Pydantic V2

### Description

During model construction, pydantic doesn't pull Tags from inner named type alias annotations, but will do it if the entire union is a named type alias, as seen in the example below.

Results in the following error:

```
PydanticUserError: `Tag` not provided for choice {'type': 'definition-ref', 'schema_ref': '__main__.TaggedApplePie:140478632382272'} used with `Discriminator`
```

### Example Code

```Python
from typing import Annotated, Any, Literal

from pydantic import BaseModel, Discriminator, Tag


class Pie(BaseModel):
    time_to_cook: int
    num_ingredients: int


class ApplePie(Pie):
    fruit: Literal['apple'] = 'apple'


class PumpkinPie(Pie):
    filling: Literal['pumpkin'] = 'pumpkin'


def get_discriminator_value(v: Any) -> str:
    print("I'm discriminating!")
    if isinstance(v, dict):
        return v.get('fruit', v.get('filling'))
    return getattr(v, 'fruit', getattr(v, 'filling', None))


# This works
type Dessert = Annotated[
    (Annotated[ApplePie, Tag('apple')] | Annotated[PumpkinPie, Tag('pumpkin')]),
    Discriminator(get_discriminator_value),
]


class ThanksgivingDinner(BaseModel):
    dessert: Dessert


# This doesn't work
# Works if using implicit alias: TaggedApplePie = Annotated[ApplePie, Tag('apple')]
type TaggedApplePie = Annotated[ApplePie, Tag('apple')]


class ThanksgivingDinnerSeconds(BaseModel):
    dessert: Annotated[
        (TaggedApplePie | Annotated[PumpkinPie, Tag('pumpkin')]),
        Discriminator(get_discriminator_value),
    ]
```

### Python, Pydantic & OS Version

```Text
pydantic version: 2.11.4
        pydantic-core version: 2.33.2
          pydantic-core build: profile=release pgo=false
               python version: 3.12.9 | packaged by Anaconda, Inc. | (main, Feb  6 2025, 18:56:27) [GCC 11.2.0]
                     platform: Linux-5.4.0-172-generic-x86_64-with-glibc2.31
             related packages: pydantic-extra-types-2.10.4 pydantic-settings-2.9.1 fastapi-0.115.12 typing_extensions-4.13.2
                       commit: unknown
```

Hints from issue discussion:

Commit message: Allow callable discriminator to be applied on PEP 695 type aliases

Patch to review:
diff --git a/pydantic/types.py b/pydantic/types.py
index b5c4fd6060..1e690fc146 100644
--- a/pydantic/types.py
+++ b/pydantic/types.py
@@ -3074,9 +3074,11 @@ def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaH
             return handler(Annotated[source_type, Field(discriminator=self.discriminator)])
         else:
             original_schema = handler(source_type)
-            return self._convert_schema(original_schema)
+            return self._convert_schema(original_schema, handler)
 
-    def _convert_schema(self, original_schema: core_schema.CoreSchema) -> core_schema.TaggedUnionSchema:
+    def _convert_schema(
+        self, original_schema: core_schema.CoreSchema, handler: GetCoreSchemaHandler | None = None
+    ) -> core_schema.TaggedUnionSchema:
         if original_schema['type'] != 'union':
             # This likely indicates that the schema was a single-item union that was simplified.
             # In this case, we do the same thing we do in
@@ -3093,10 +3095,23 @@ def _convert_schema(self, original_schema: core_schema.CoreSchema) -> core_schem
             if metadata is not None:
                 tag = metadata.get('pydantic_internal_union_tag_key') or tag
             if tag is None:
-                raise PydanticUserError(
-                    f'`Tag` not provided for choice {choice} used with `Discriminator`',
-                    code='callable-discriminator-no-tag',
-                )
+                # `handler` is None when this method is called from `apply_discriminator()` (deferred discriminators)
+                if handler is not None and choice['type'] == 'definition-ref':
+                    # If choice was built from a PEP 695 type alias, try to resolve the def:
+                    try:
+                        choice = handler.resolve_ref_schema(choice)
+                    except LookupError:
+                        pass
+                    else:
+                        metadata = cast('CoreMetadata | None', choice.get('metadata'))
+                        if metadata is not None:
+                            tag = metadata.get('pydantic_internal_union_tag_key') or tag
+
+                if tag is None:
+                    raise PydanticUserError(
+                        f'`Tag` not provided for choice {choice} used with `Discriminator`',
+                        code='callable-discriminator-no-tag',
+                    )
             tagged_union_choices[tag] = choice
 
         # Have to do these verbose checks to ensure falsy values ('' and {}) don't get ignored
diff --git a/tests/test_discriminated_union.py b/tests/test_discriminated_union.py
index fe21fabf4f..e2720ebe96 100644
--- a/tests/test_discriminated_union.py
+++ b/tests/test_discriminated_union.py
@@ -2203,6 +2203,34 @@ class Reference(BaseModel):
     assert Base.__pydantic_core_schema__ == base_schema
 
 
+def test_tagged_discriminator_type_alias() -> None:
+    """https://github.com/pydantic/pydantic/issues/11930"""
+
+    class Pie(BaseModel):
+        pass
+
+    class ApplePie(Pie):
+        fruit: Literal['apple'] = 'apple'
+
+    class PumpkinPie(Pie):
+        filling: Literal['pumpkin'] = 'pumpkin'
+
+    def get_discriminator_value(v):
+        return v.get('fruit', v.get('filling'))
+
+    TaggedApplePie = TypeAliasType('TaggedApplePie', Annotated[ApplePie, Tag('apple')])
+
+    class ThanksgivingDinner(BaseModel):
+        dessert: Annotated[
+            Union[TaggedApplePie, Annotated[PumpkinPie, Tag('pumpkin')]],
+            Discriminator(get_discriminator_value),
+        ]
+
+    inst = ThanksgivingDinner(dessert={'fruit': 'apple'})
+
+    assert isinstance(inst.dessert, ApplePie)
+
+
 def test_discriminated_union_type_alias_type() -> None:
     """https://github.com/pydantic/pydantic/issues/11661
 


Code review comments:
diff_hunk: @@ -3093,10 +3095,23 @@ def _convert_schema(self, original_schema: core_schema.CoreSchema) -> core_schem
             if metadata is not None:
                 tag = metadata.get('pydantic_internal_union_tag_key') or tag
             if tag is None:
-                raise PydanticUserError(
-                    f'`Tag` not provided for choice {choice} used with `Discriminator`',
-                    code='callable-discriminator-no-tag',
-                )
+                # `handler` is None when this method is called from `apply_discriminator()` (deferred discriminators)
+                if handler is not None and choice['type'] == 'definition-ref':
+                    # If choice was built from a PEP 695 type alias, try to resolve the def:
+                    try:
+                        choice = handler.resolve_ref_schema(choice)
+                    except LookupError:
+                        pass
+                    else:
+                        metadata = cast('CoreMetadata | None', choice.get('metadata'))
+                        if metadata is not None:
+                            tag = metadata.get('pydantic_internal_union_tag_key') or tag
path: pydantic/types.py
text: @user1:
`tag` is known to be `None` here so we don't need the `or tag`