2025, Oct 02 03:00

Why NewType over tuple[str,str] breaks in Python injector (UnknownProvider) and how TYPE_CHECKING fixes it

Learn why Python dependency injection fails for NewType wrapping tuple[str,str] (UnknownProvider), fix it using TYPE_CHECKING to bind runtime types in injector.

Dependency injection in Python often leans on runtime types, while our annotations are increasingly expressive thanks to typing. The friction appears when those worlds meet. A common case: binding a NewType based on a parameterized generic such as tuple[str, str] in injector fails at runtime with an UnknownProvider.

The failing setup

The following minimal example binds a NewType that wraps a typed tuple and then tries to resolve it:

import injector
from typing import NewType
PairLabel = NewType("PairLabel", tuple[str, str])
class AppModule(injector.Module):
    def configure(self, link: injector.Binder) -> None:
        link.bind(PairLabel, PairLabel(("x", "y")))
injector.Injector(modules=[AppModule]).get(PairLabel)

At runtime this leads to injector.UnknownProvider complaining it cannot determine a provider for the interface-to-instance pair. That’s the symptom; the cause lies deeper in how Python represents parameterized generics at runtime.

What actually breaks

NewType is not the culprit. The key is that parameterized generics like tuple[str, str] are not real runtime types. They are instances of types.GenericAlias. You cannot use them where a concrete type is expected; even isinstance refuses them as the second argument. This shows the difference clearly:

>>> type(tuple[str, str])
<class 'types.GenericAlias'>
>>> isinstance(tuple[str, str], type)
False
>>> isinstance(tuple, type)
True
>>> isinstance(("foo", "bar"), tuple[str, str])
Traceback (most recent call last):
  ...
TypeError: isinstance() argument 2 cannot be a parameterized generic

In other words, you never actually have an instance of tuple[str, str] at runtime. It’s only a static type construct. Trying to inject one therefore doesn’t make sense to the DI framework, which operates on real runtime types and instances. A NewType based on a concrete type like tuple or str, however, is fine because those are actual runtime types; a NewType('Baz', tuple) would work for the same reason.

A practical workaround

The pattern that unblocks this is to keep precise types for static checking while giving runtime a concrete type to bind. typing.TYPE_CHECKING lets you branch definitions for the two worlds without affecting the running program.

import injector
from typing import NewType, TYPE_CHECKING, reveal_type
if TYPE_CHECKING:
    PairLabel = NewType("PairLabel", tuple[str, str])
else:
    PairLabel = NewType("PairLabel", tuple)
class AppModule(injector.Module):
    def configure(self, link: injector.Binder) -> None:
        link.bind(PairLabel, PairLabel(("x", "y")))
reveal_type(injector.Injector(modules=[AppModule]).get(PairLabel))

With this approach, static analysis sees tuple[str, str] (or the NewType wrapping it), while runtime gets a concrete tuple. The injector resolves the binding correctly because it’s working with a real type.

Why it matters

Dependency injection containers resolve interfaces and keys using what exists at runtime. Parameterized generics are erased to GenericAlias and cannot serve as concrete runtime types. Understanding this boundary keeps type hints useful without leaking static-only constructs into the DI wiring. It also explains why a NewType wrapping str behaves well in injection scenarios, while a NewType wrapping tuple[str, str] does not.

Conclusion

When binding values for injection, stick to concrete runtime types. If you need the precision of tuple[str, str] for tooling and code quality, define your NewType under an if TYPE_CHECKING guard to expose tuple[str, str] to the type checker and the plain tuple to the runtime. This keeps injector happy and preserves static guarantees where they belong.

The article is based on a question from StackOverflow by zyxue and an answer by Anerdw.