How do I hint a function that returns a union of types

Hello,

I have a function which builds a union based on a tuple of types that gets passed in.
Programmatically this works fine, but how do I create a proper type hint?
I expected this to work, since in the docs it mentions t hat the TypeVarTuple can be used anywhere a normal TypeVar. However this does not seem to be entirely correct since mypy complains.
What would be the proper way to annotate this function?
Thank you for your help

from typing import TypeVarTuple, Union
Ts = TypeVarTuple('Ts')

def create_model_union(models: tuple[*Ts]) -> Union[*Ts]:
    return Union[*models]


class A:
    pass

class B:
    pass


res = create_model_union((A, B))

reveal_type(res)

See mypy Playground

main.py:4: error: Unpack is only valid in a variadic position  [valid-type]
main.py:17: note: Revealed type is "Any"
Found 1 error in 1 file (checked 1 source file)

Iirc this is a problem I’ve encountered before. As far as I know, there is no real solution. I’ve tried types.UnionType, which sadly doesn’t work. _SpecialForm would work, but is too broad I guess.
Maybe ask someone who knows a lot about typing, like @Jelle (sorry for the ping)?

Union[*Ts] was originally part of PEP 646 but it was later removed due to the high implementation complexity compared to how often it seemed to be genuinely useful.

So the short answer is, you can’t do that. Not until someone writes a new proposal that adds this ability back in and demonstrates enough compelling use-cases in order to convince the typing council that it should be added back in.

I agree that the text in the typing spec is misleading. While it is syntactically valid to put an unpacked TypeVarTuple anywhere you could previously put a TypeVar, it’s only semantically valid if the surrounding generic is variadic.

The main issue is that Union is not a generic, it’s a special form, so it’s also not a variadic generic, even though it looks like one from its use. It needs special casing in type checkers.

Thank you all for your quick replies.

I’m not very firm with terminology, but if I understand you correctly this should be valid then?

def create_model_union(*models: *Ts) -> Union[*Ts]:

But after reading your statement once again the issue seems to be the returned Union[Ts]


All classes I pass in the function have a common base class.
I’ve fallen back to this type hint.

def create_model_union(models: tuple[type[BaseClass], ...]) -> BaseClass:

Is there a better way to type hint or is this it?

You could add overloads for the most common cases, i.e. one model, two models, three models and then have a generic fallback overload if more than three models have been passed in.

Typeshed uses this trick in quite a few places, like e.g. the builtin map, which would require mapped types in order to be typed more accurately with a single signature.

@overload
def create_model_union(models: tuple[type[T1]]) -> T1: ...
@overload
def create_model_union(models: tuple[type[T1], type[T2]]) -> T1 | T2: ...
@overload
def create_model_union(models: tuple[type[T1], type[T2], type[T3]) -> T1 | T2 | T3: ...
@overload
def create_model_union(models: tuple[type[T], type[T], type[T], type[T], *tuple[type[T], ...]) -> T: ...

It’s still useful to make the final overload generic, since it will automatically solve to the most specific type that’s still valid, which could be more specific than BaseClass.

If the funtion returns a UnionType instance, the return annotation should be UnionType, isn’t it?

@Daverball
Thank you for showing me this, I think I’ll go down this route.
I checked my code and it seems the maximum amount of classes seems to be around 25.
Writing a small script which generates ~30 overloads seems trivial even though the resulting code will be quite verbose. But at least it will work properly and most importantly will be correct.

I think having some kind of generic types.UnionType[T] would be cool. Then we could have T be a TypeVar, which each item has to be assignable to.
E.g.:

from typing import TypeVar
from types import UnionType

T = TypeVar("T")

def Fn(*args: T) -> UnionType[T]:
    ...

Or:

from types import UnionType

tp = int

def Fn(*args: tp) -> UnionType[tp]:
    ...

I missed that the function in the initial example is returning a UnionType instance. Since Union isn’t generic, the correct return type for the example is UnionType, as @storchaka has pointed out. Until PEP 747 is accepted and implemented in type checkers we don’t have a more precise type for the type of a type union.

I’m not really sure what the purpose of this function is or why it needs to be generic in the first place. Are you using it for something like pydantic’s TypeAdapter where a type expression is passed as a value and used to bind a type parameter? That only really works for instances of type. So UnionType is a no-go and you will need to manually write the type until we have PEP 747.

You have better chances going the other way, defining a TypeAlias for the Union and getting the individual members of the union back using typing.get_args wherever you need them.

Initially UnionType was made not subscriptable.

>>> from types import UnionType
>>> UnionType[int, str]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'type' object is not subscriptable

It is not generic type, and if make it subscriptable, it could be confused with typing.Union[...] and with generic types.

But in 3.14 it was made subscriptable. Was not this a mistake?

Just to clarify, it’s not that types.UnionType was made subscriptable, but that types.UnionType and typing.Union were made to be the same thing. See https://github.com/python/cpython/pull/105511 for the full rationale and discussion

I have many pydantic models and I use the union with a discriminator.
The function does some checks and creates the Union:
This would be fine to do by hand but I also have a couple of mixins which add additional fields for all these models. I don’t want to do this by hand because it’s error prone and there are many models and every mixin has to be done for all models. I’m aware that the Mixin functionality and fields will not be type checked.

Something like this:

def create_model_union(models: tuple[*Ts], mixin: type[_BaseModel] | None = None, discriminator: str) -> Union[*Ts]:

    for model in models:
        ... # some model checks
        if mixin is not None:
            new_model = create_model(model_name, __base__=(model, mixin))
    
    return Annotated[Union[*tuple(new_models)], Field(discriminator=discriminator)]

I’m not sure if this is the most elegant way to do it.
I’m currently thinking maybe it’s better to automatically generate the python code instead, but that requires much more effort.

Thank you for reference. I am now even more convinced that this is a mistake.

The function itself is perfectly fine, the return type is just not correct. What you’re doing in a more simplified way is essentially this:

def foo() -> int:
    return int

The type and its value are not equivalent, the type of a type is type[int] in this case. The type of a Union is UnionType, but since you’re wrapping it in Annotated, it’s techically _AnnotatedAlias.

We just don’t really have any real metaprogramming capabilities yet when it comes to types. PEP 747 would give us some additional flexibility there. Until then you’re often better off sticking with Any, since the type checker will not be able to understand what you’re doing anyways.

But even with PEP 747 you can’t really use functions in type annotations, you can only use them to pass them into other functions that expect type arguments. So you’re still somewhat limited in how much you can accomplish using metaprogramming.

Opened Merge 'types.UnionType' with `typing._UnionGenericAlias`, not `typing.Union` · Issue #137065 · python/cpython · GitHub to partially revert that change.

1 Like

I think the return type should be this:

def create_model_union[*Ts](models: tuple[*Ts]) -> type[Union[*Ts]]:
    return Union[*models]

analogous to

def return_int() -> type[int]:
    return int

though of course no type checker will actually handle type[Union[*Ts]] as intended. And I’m not sure it’s worth it to make this work.

However, it would be nice if this worked:

import random

def pick_one[*Ts](*args: *Ts) -> Union[*Ts]:
    return args[random.randint(0, len(args) - 1)]
2 Likes