Having experimented some more, I think I have something that more closely matches your initial requirements.
normalize(int, "1")
# 1
normalize(int, ["1", "2", "3"])
# ValueError: "can't convert list of data with non-list type parameter <class 'int'>"
normalize(list[int], "1")
# [1]
normalize(list[int], ["1", "2", "3"])
# [1, 2, 3]
It’s been a fun exercise but I’m not 100% satisfied with what I came up with.
First some things I found interesting about list[T]
and Type[T]
.
A parameter with type annotation Type[T]
can accept - as the typing
docs put it - “the class object of T” (or a subtype of T). When used as an annotation, list[T]
is a type from the perspective of a static analysis tool (e.g. Pylance). But when passed as a parameter at runtime, it’s not actually a type per-se:
- It’s class is not
type
- It’s not an instance of a metaclass
- It isn’t
type
itself
At runtime we’re no longer dealing with the static type system but with Python’s actual dynamic type system, and in that scope an argument list[T]
is a value (an object that is an instance of the class types.GenericAlias
) and not a ‘type’ (because it can’t be used to construct instances.)
(…in hindsight this feels obvious, but I had to walk myself there.)
So in some sense, I almost feel like list[int]
isn’t a valid value for a parameter of Type[T]
, because it isn’t a class. But hey, generics are weird, so there’s another level of indirection happening that I don’t fully grok the implementation of… and by intuition it seems like the correct annotation, so here we are. 
Anyway, all that said, here’s what I came up with:
from typing import (
Type,
TypeVar,
get_args, # !
get_origin, # !
)
from types import GenericAlias
T = TypeVar("T")
def normalize(cls_ish: Type[T], data: str | list[str]) -> T:
match cls_ish:
case GenericAlias():
container_type = get_origin(cls)
if container_type is not list:
raise TypeError(f"unsupported container type {cls}")
item_type, *_ = get_args(cls)
ndata = data if isinstance(data, list) else [data]
return container_type(map(item_type, ndata))
case Type: # no parens '()' !
if isinstance(data, list):
raise ValueError(f"can't convert list of data with non-list type parameter {cls}")
return cls(data)
- I’ve left the
Type[T]
annotation as is, since functionally it does what is intended.
get_args
and get_origin
are fun little helper functions I didn’t know about before: typing — Support for type hints — Python 3.11.4 documentation
- It’s not incredibly robust, in particular there’s nothing constraining
Type[T]
to classes that accept a string as an initializer argument.
- It might be nice to generalize beyond
list
to support say a collection type like Sequence
, but this is tricky because strings are sequences of characters, which makes it hard to distinguish when data is a single value vs when data is a collection.