Unpacking a union of tuples

It seems to not be possible to typing.Unpack a union of objects such
that the following code snippet works:

from typing import Unpack, assert_type
def foo(*bar: Unpack[tuple[int,...]|tuple[str,...]]):
    for baz in bar:
        assert_type(baz, int|str)
    ...

I am aware that you can use typing.overload to define it like

from typing import Unpack, assert_type, overload
@overload
def foo(*bar: Unpack[tuple[int,...]]):
    ...

@overload
def foo(*bar: Unpack[tuple[str,...]]):
    ...

def foo(*bar: Unpack[tuple[int|str,...]]):
    for baz in bar:
        assert_type(baz, int|str)
    ...

but I specifically have a typing.TypeAlias which is a union of a
bunch of tuples, so overloading the function by listing out all the
possibilities is not only arduous but also defeats the purpose.

I tried to see if something like

from typing import Unpack, assert_type
def foo(*bar: Unpack[tuple[Unpack[tuple[int,...]]|Unpack[tuple[str,...]]]]):
    for baz in bar:
        assert_type(baz, int|str)
    ...

would work, which doesn’t report a type issue on the argument
declaration itself but the revealed type for baz ends up becoming
*tuple[int,...]|*tuple[str,...] which seems bogus.

Is there a obvious (or not) solution that I am missing or is what I
am thinking of not possible as is (or otherwise)?

1 Like

Why use Unpack here at all? Or is this a toy example that doesn’t reflect your actual use-case? Because your toy example can be expressed simply as:

def foo(*bar: int | str): ...

I think that’s equivalent to tuple[int | str, ...], distinct from “a tuple of all ints OR a tuple of all str”. That is, it allows mixing.

Isn’t there an extra set of nesting there? What about

def foo(*bar: Unpack[tuple[int,...]] | Unpack[tuple[str,...]]):

Yes, this is just an MRA, in my actual case (to my understanding) I can’t represent the type normally.
Also, I don’t believe that is an equivalent type to *(tuple[str,...]|tuple[int,...])?

That wasn’t an idea I had considered, but it appears to be identical to the original code:

from typing import Unpack, assert_type
def foo(*bar: Unpack[tuple[int,...]] | Unpack[tuple[str,...]]):
    for baz in bar:
        assert_type(baz, int|str) # Incorrect: `baz` is of type `*tuple[int,...] | *tuple[str,...]`
    ...

Technically it’s not quite the same, you’re correct, since the Union is applied to each element, rather than the whole tuple. But it fulfills the assert_type condition you used in your example. It wasn’t quite clear whether you cared about the element type or the type of the whole tuple. Going by your examples I assumed you only cared about the element type[1].

You could probably achieve what you wanted to achieve if you didn’t use varargs and just replaced it with a single tuple argument instead. I’m not surprised that neither a Union of Unpack nor an Unpack of a Union works correctly. It seems like a very fringe use-case.


  1. since it matches your overload solution ↩︎

Sorry, I see that I was a bit unclear. This is much closer to what I am trying to achieve:

DesignatorTuple: TypeAlias = (
      tuple[Literal[True]]
    | tuple[Literal[True], Literal[None,'normalized']]
    | tuple[Literal[False], Literal['unfiltered','filtered','normalized']]
)
def find_designated(*args: *DesignatorTuple):
    ...

And I do agree that it’s quite fringe, but it makes sense to me that it could work, at the very least.

This seems like something that would be easier to read as an overloaded function, especially since you can do it in two overloads with a default parameter. I don’t really see the point of trying to make this work with tuple unpacking, unless you’re trying to pass additional information into the function, so code flow analysis can correctly infer that unfiltered/filtered for the second parameter can only occur if the first parameter is True. But it doesn’t really seem worth obfuscating the function to the reader for that arguably very small benefit.

I also checked your Union of Unpack example in pyright and mypy playground. It seems like what you’re seeing is a pyright bug. mypy rejects both a Union of Unpack and an Unpack of a Union. Neither are really part of PEP-646, but only the latter is explicitly forbidden, that’s probably why pyright doesn’t detect the former as an invalid pattern but gives back a bogus type, because it’s not necessarily well-defined.

I see your point about obfuscation, and I am considering cleaning the code up to avoid having this issue, myself.

As for PEP-646 (assuming I correctly understood the section you are referring to), I interpreted that to only be with respect to TypeVarTuples; that is, «Instances of an (unpacked) TypeVarTuple cannot be “upgraded” (“widened”, “relaxed”, etc.)», rather than it being a statement about Unpack, Union, and Tuple in general. I think the use of Union there is an example and not a statement about typing.Union.

Explicitly forbidden may have been a poor choice of words on my part. I was talking about the fact that the PEP only allows Unpack to be used on either a TypeVarTuple or a tuple, so it follows that it is not allowed to be used with anything else, which includes Union[1].


  1. This was later expanded to unpacking a TypedDict in kwargs, but there have been no further additions thus far ↩︎

Ah. You’re right. I’m trying to figure out what the semantics of Unpack accepting Union would be, but I am coming up short. I will let the issue rest for now, then. Thank you very much for all the help.