Repository: pydantic/pydantic
Issue category: Feature Request

Support TypeVar with Identical Bound and Default
### Initial Checks

- [X] I have searched Google & GitHub for similar requests and couldn't find anything
- [X] I have read and followed [the docs](https://docs.pydantic.dev) and still think this feature is missing

### Description

# The Problem

Currently Pydantic [does not support](https://github.com/pydantic/pydantic/blob/07b64739a251760a71082324e358c9d8291b7ab6/pydantic/_internal/_generate_schema.py#L1667) `TypeVar` with more than one of a bound, default, or constraints declared. This is a problem because users are forced to choose between better usability or greater correctness. As described in the [motivations](https://peps.python.org/pep-0696/#motivation) for the PEP which added `TypeVar` defaults declaring both a bound a default is relatively common:

```python
from typing_extensions import TypeVar

T = TypeVar("T", bound=int, default=int)

def f(x: T = 1) -> T:
    return x

f("hello")  # error because str not subtype of int
```

If we're forced to only declare a `default` we won't get appropriate type warnings:

```python
from typing_extensions import TypeVar

T = TypeVar("T", default=int)

def f(x: T = 1) -> T:
    return x

f("hello")  # no error
```

# Partial Solution

After inspecting the downstream code which [handles bounds and defaults](https://github.com/pydantic/pydantic/blob/07b64739a251760a71082324e358c9d8291b7ab6/pydantic/_internal/_generate_schema.py#L1670-L1681), it appears that it would relatively easy to at least support the case of a `TypeVar` with an identical bound and default declared. From my anecdotal experience, this likely covers the majority of cases where someone might want to specify both a bound and a default.

Supporting this would involve rewriting:

```python
        if (bound is not None) + (len(constraints) != 0) + (default is not None) > 1:
            raise NotImplementedError(
                'Pydantic does not support mixing more than one of TypeVar bounds, constraints and defaults'
            )

        if default is not None:
            return self.generate_schema(default)
        elif constraints:
            return self._union_schema(typing.Union[constraints])  # type: ignore
        elif bound:
            schema = self.generate_schema(bound)
            schema['serialization'] = core_schema.wrap_serializer_function_ser_schema(
                lambda x, h: h(x), schema=core_schema.any_schema()
            )
            return schema
        else:
            return core_schema.any_schema()
```

To look more like:

```python
        if len(constraints) != 0:
            if (bound is not None or default is not None):
                raise RuntimeError(...)
            return self._union_schema(typing.Union[constraints])
        if bound is not None and default is not None and bound != default:
            raise RuntimeError(...)
        else:
            schema = generate_schema(bound or default)
            if bound is not None:
                schema['serialization'] = core_schema.wrap_serializer_function_ser_schema(
                    lambda x, h: h(x), schema=core_schema.any_schema()
                )
            return schema
```

The main question I have is how feasible it is to actually check `bound != default`. Besides that, this seems fairly sound.

### Affected Components

- [ ] [Compatibility between releases](https://docs.pydantic.dev/changelog/)
- [X] [Data validation/parsing](https://docs.pydantic.dev/concepts/models/#basic-model-usage)
- [ ] [Data serialization](https://docs.pydantic.dev/concepts/serialization/) - `.model_dump()` and `.model_dump_json()`
- [ ] [JSON Schema](https://docs.pydantic.dev/concepts/json_schema/)
- [ ] [Dataclasses](https://docs.pydantic.dev/concepts/dataclasses/)
- [ ] [Model Config](https://docs.pydantic.dev/concepts/config/)
- [ ] [Field Types](https://docs.pydantic.dev/api/types/) - adding or changing a particular data type
- [ ] [Function validation decorator](https://docs.pydantic.dev/concepts/validation_decorator/)
- [X] [Generic Models](https://docs.pydantic.dev/concepts/models/#generic-models)
- [ ] [Other Model behaviour](https://docs.pydantic.dev/concepts/models/) - `model_construct()`, pickling, private attributes, ORM mode
- [ ] [Plugins](https://docs.pydantic.dev/) and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.


Functions to edit:
pydantic/_internal/_generate_schema.py:GenerateSchema._unsubstituted_typevar_schema

Patch:
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
index bebe58c6c0..c9bceddb99 100644
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -1892,34 +1892,27 @@ def _call_schema(self, function: ValidateCallSupportedTypes) -> core_schema.Call
         )
 
     def _unsubstituted_typevar_schema(self, typevar: typing.TypeVar) -> core_schema.CoreSchema:
-        assert isinstance(typevar, typing.TypeVar)
-
-        bound = typevar.__bound__
-        constraints = typevar.__constraints__
-
         try:
-            typevar_has_default = typevar.has_default()  # type: ignore
+            has_default = typevar.has_default()
         except AttributeError:
-            # could still have a default if it's an old version of typing_extensions.TypeVar
-            typevar_has_default = getattr(typevar, '__default__', None) is not None
+            # Happens if using `typing.TypeVar` on Python < 3.13
+            pass
+        else:
+            if has_default:
+                return self.generate_schema(typevar.__default__)
 
-        if (bound is not None) + (len(constraints) != 0) + typevar_has_default > 1:
-            raise NotImplementedError(
-                'Pydantic does not support mixing more than one of TypeVar bounds, constraints and defaults'
-            )
+        if constraints := typevar.__constraints__:
+            return self._union_schema(typing.Union[constraints])
 
-        if typevar_has_default:
-            return self.generate_schema(typevar.__default__)  # type: ignore
-        elif constraints:
-            return self._union_schema(typing.Union[constraints])  # type: ignore
-        elif bound:
+        if bound := typevar.__bound__:
             schema = self.generate_schema(bound)
             schema['serialization'] = core_schema.wrap_serializer_function_ser_schema(
-                lambda x, h: h(x), schema=core_schema.any_schema()
+                lambda x, h: h(x),
+                schema=core_schema.any_schema(),
             )
             return schema
-        else:
-            return core_schema.any_schema()
+
+        return core_schema.any_schema()
 
     def _computed_field_schema(
         self,

Test patch: diff --git a/tests/test_generics.py b/tests/test_generics.py
index 2e11d871f8..ea92e7aba7 100644
--- a/tests/test_generics.py
+++ b/tests/test_generics.py
@@ -2836,14 +2836,93 @@ class MyErrorDetails(ErrorDetails):
     }
 
 
-def test_mix_default_and_constraints() -> None:
-    T = TypingExtensionsTypeVar('T', str, int, default=str)
+def test_serialize_typevars_default_and_bound_with_user_model() -> None:
+    class MyErrorDetails(BaseModel):
+        bar: str
+
+    class ExtendedMyErrorDetails(MyErrorDetails):
+        foo: str
 
-    msg = 'Pydantic does not support mixing more than one of TypeVar bounds, constraints and defaults'
-    with pytest.raises(NotImplementedError, match=msg):
+    class MoreExtendedMyErrorDetails(ExtendedMyErrorDetails):
+        suu: str
 
-        class _(BaseModel, Generic[T]):
-            x: T
+    T = TypingExtensionsTypeVar('T', bound=MyErrorDetails, default=ExtendedMyErrorDetails)
+
+    class Error(BaseModel, Generic[T]):
+        message: str
+        details: T
+
+    # bound small parent model
+    sample_error = Error[MyErrorDetails](
+        message='We just had an error',
+        details=MyErrorDetails(foo='var', bar='baz', suu='suu'),
+    )
+
+    assert sample_error.details.model_dump() == {
+        'bar': 'baz',
+    }
+    assert sample_error.model_dump() == {
+        'message': 'We just had an error',
+        'details': {
+            'bar': 'baz',
+        },
+    }
+
+    # default middle child model
+    sample_error = Error(
+        message='We just had an error',
+        details=MoreExtendedMyErrorDetails(foo='var', bar='baz', suu='suu'),
+    )
+
+    assert sample_error.details.model_dump() == {
+        'foo': 'var',
+        'bar': 'baz',
+        'suu': 'suu',
+    }
+    assert sample_error.model_dump() == {
+        'message': 'We just had an error',
+        'details': {'foo': 'var', 'bar': 'baz'},
+    }
+
+    # bound big child model
+    sample_error = Error[MoreExtendedMyErrorDetails](
+        message='We just had an error',
+        details=MoreExtendedMyErrorDetails(foo='var', bar='baz', suu='suu'),
+    )
+
+    assert sample_error.details.model_dump() == {
+        'foo': 'var',
+        'bar': 'baz',
+        'suu': 'suu',
+    }
+    assert sample_error.model_dump() == {
+        'message': 'We just had an error',
+        'details': {
+            'foo': 'var',
+            'bar': 'baz',
+            'suu': 'suu',
+        },
+    }
+
+
+def test_typevars_default_model_validation_error() -> None:
+    class MyErrorDetails(BaseModel):
+        bar: str
+
+    class ExtendedMyErrorDetails(MyErrorDetails):
+        foo: str
+
+    T = TypingExtensionsTypeVar('T', bound=MyErrorDetails, default=ExtendedMyErrorDetails)
+
+    class Error(BaseModel, Generic[T]):
+        message: str
+        details: T
+
+    with pytest.raises(ValidationError):
+        Error(
+            message='We just had an error',
+            details=MyErrorDetails(foo='var', bar='baz'),
+        )
 
 
 def test_generic_with_not_required_in_typed_dict() -> None: