Search code examples
pythonpyrightpython-3.12

How do you write a statically typed Python function that converts a string to a parameterized type?


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?


Solution

  • 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"))