2026, Jan 13 01:00

Understanding the Type Ambiguity Behind to_list Helpers in Python: Why Pylance Flags Generic listify APIs

Learn why a fully generic listify/to_list helper is unsafe in Python typing. Pylance reveals TypeVar conflicts, tuple[T, ...] edge cases, and safer API design.

Typing a utility that turns "anything into a list" looks trivial, right up until a static type checker steps in. A signature that seems obvious at first glance starts to leak ambiguity, and Pylance is quick to call it out. The goal here is to clarify why a fully generic listify-style function is unsafe, what exactly triggers the warnings, and why neither cast() nor # type: ignore is the right way out.

The tempting API and where it breaks

Consider a helper that accepts a single element, an existing list or tuple of elements, or None, and always returns a list of those elements. The idea feels ergonomic for call sites and compact to annotate.

from typing import TypeVar

U = TypeVar("U")

def to_list(value: U | list[U] | tuple[U, ...] | None) -> list[U]:
    if value is None:
        return []
    elif isinstance(value, list):
        return value
    elif isinstance(value, tuple):
        return list(value)
    else:
        return [value]

Despite the straightforward control flow, a checker like Pylance reports that the return type is partially unknown. The friction appears even though tuple[U, ...] correctly denotes a variadic homogeneous tuple and isinstance(value, list) is the right runtime test.

Why the type ambiguity exists

The core issue is not in the implementation but in the generic contract. Imagine a generic function that delegates to the helper:

def call_site[T](arg: T):
    out = to_list(arg)

What is the type of out? The apparent answer would be list[T]. But the helper can also accept list[T], tuple[T, ...], or None. If T itself is list[Something], tuple[Something, ...], or None, the runtime outcome doesn’t match the static expectation list[T]. It could yield a list[Something], a list[list[Something]], or an empty list, depending on the actual shape of T. That ambiguity means there is no single substitution for T that satisfies every branch of the function. The return type depends on whether the input was already a container or not, and a static checker cannot safely infer one consistent generic mapping.

This is not just a Pylance quirk. Even after completely stripping the implementation, the ambiguity remains. Take a distilled variant:

def g[V](x: V | list[V]) -> list[V]:
    raise NotImplementedError

reveal_type(g([3]))

The type checker reports that the revealed type is builtins.list[Never] and raises an error because the constraints deduced from x being both V and list[V] conflict. The same fundamental conflict drives Pylance to be strict about the original helper.

Attempts to encode the intent by constraining one type variable to generic container types also run into tooling limits. Defining something like a second type variable constrained to list[A] or tuple[A] runs afoul of the restriction that a TypeVar constraint type cannot be generic, which means you cannot express this "container-of-A or A" relationship in a way that resolves the ambiguity for Pylance.

The resolution: accept that fully generic listify is unsafe

There is no way to make a fully generic listify safe. The checker cannot reconcile a single generic T that must simultaneously describe both an atomic element and containers of that element, while also allowing None. Pylance surfaces this more strictly than some other tools, but the underlying ambiguity exists regardless of the checker.

Even in the minimized form, where the body raises NotImplementedError, the conflict still appears, demonstrating that the problem is purely in the type relationship:

def h[X](item: X | list[X]) -> list[X]:
    raise NotImplementedError

reveal_type(h([3]))

The type checker’s output shows the contradiction explicitly, with the revealed type collapsing to Never and the call being rejected because the inferred constraints cannot be satisfied together.

Why this matters

The takeaway is that some API designs encode runtime branching that static typing cannot represent without losing safety. If the return type depends on whether the input was a container or a scalar of the same generic parameter, the single-parameter generic abstraction breaks down. Pylance flags this as partially unknown or outright incompatible because accepting that signature would force it to make unsound inferences. Recognizing these boundaries helps avoid brittle annotations and unexpected type holes in real code.

Practical guidance

When you need a helper that normalizes inputs, avoid collapsing scalars and containers into a single fully generic signature. The moment the same type parameter must stand for both an element and a container of that element, the type system no longer has a consistent mapping. Be mindful that using tuple[T, ...] is required for variadic homogeneous tuples, and that runtime checks like isinstance(x, list) are correct but do not rescue an ambiguous type design. Also be aware that trying to constrain a type variable to generic container types can be rejected with errors like “TypeVar constraint type cannot be generic,” so this path doesn’t resolve the issue either.

The safest course is to acknowledge the limitation and avoid promising a static contract that a type checker cannot enforce. That keeps both your annotations and your tooling honest.