I would like to write a statically typed Python function that accepts a general type and a string and that returns a value of the given type, derived from the given string. For example:
>>> parse(int | None, "5")
5
>>> parse(int | None, "") is None
True
Here's a Python 3.12 attempt (which has a problem in the type signature since union types can't be assigned to variables of type type
):
def parse[a](x: type[a], v: str) -> a:
if x in (bool, bool | None):
return bool(v)
if x in (float, float | None):
return float(v)
if x in (int, int | None):
return int(v)
if x in (str, str | None):
return v
raise ValueError
However, this code yields errors with Pyright 1.1.349:
error: Expression of type "bool" cannot be assigned to return type "a@parse"
error: Expression of type "float" cannot be assigned to return type "a@parse"
error: Expression of type "int" cannot be assigned to return type "a@parse"
How can this be fixed?
In short, this is a problem of parametricity. Your function is not the same for every possible type A
.
With the signature
def parse[A](x: type[A], v: str) -> A:
...
the type checker has no idea what A
is, only that a value of type A
is required. As such, the only way to get such a value is to call x
. The following trivial definition would type-check:
def parse[A](x: type[A], v: str) -> A:
return x()
Your function, however, tries to return a value of type bool
, or type str
, or type int
, etc, which (statically speaking) is distinct from a value of type A
, even if A
is bound at runtime to bool
or str
or int
. Type narrowing does not apply to a generic type.
One workaround is to pass not the type itself, but a string representing the type, and using typing.overload
to implement ad-hoc polymorphism rather than parametric polymorphism.
from typing import overload, Literal
@overload
def parse(x: Literal["str"], v: str) -> str:
...
@overload
def parse(x: Literal["float"], v: str) -> float:
...
@overload
def parse(x: Literal["int"], v: str) -> int:
...
@overload
def parse(x: Literal["bool"], v: str) -> bool:
...
def parse(x, v):
match x:
case "str":
return v
case "int":
return int(v)
case "float":
return float(v)
case "bool":
return v.lower() == "true"
case _:
raise ValueError
reveal_type(parse("int", "3"))
reveal_type(parse("float", "3.0"))
reveal_type(parse("bool", "True"))
reveal_type(parse("str", "foo"))
# The following is a type error, but you were going to
# raise a runtime error anyway.
reveal_type(parse("complex", "3.9"))