python / typing

Python static typing home. Hosts the documentation and a user help forum.
https://typing.readthedocs.io/
Other
1.6k stars 240 forks source link

Support for alternative generic inference algorithms #900

Open KotlinIsland opened 3 years ago

KotlinIsland commented 3 years ago

I believe there is a use case for generic inference that doesn't get as wide as possible.

Look at this example of an assertion function, you would never want to do an assertion between two different types, but there is currently no way to type this:

from typing import TypeVar

T = TypeVar("T")

def assert_something(expected: T, actual: T) -> None:
    ...

assert_something(1, "")  # no error, SUS alert!, T is inferred as `object`

Here are some behaviors from other languages

TypeScript

In Typescript generic inference is narrowed to type level types(not down to instance level types) and is never widened:

function assertSomething<T>(expected: T, actual: T): void { ... }

assertSomething(1, "")  // Argument of type 'string' is not assignable to parameter of type 'number'.

Kotlin

Kotlin by default acts the same as Python, but there are annotations for changing the behavior of inference.

NoInfer will exclude that usage from inferring the type.

fun <T> assertSomething(expected: T, actual: @NoInfer T) { }
assertSomething(1, "")  // Type mismatch: inferred type is String but Int was expected

Exact will require the type of the parameter is equal at a type level (Number != Int)

fun <T> foo(x: @Exact T) { }
foo<Number>(1) // Type mismatch. Required: Number, Found: Int

OnlyInputTypes will require a type annotation if there is any difference in types between the usages of the generic:

fun <@OnlyInputTypes T> doSomething(a: T, b: T)  { }

val a = doSomething("a", "b")
val b = doSomething("a", 1) // Type inference failed. The value of the type parameter T should be mentioned in input types (argument types, receiver type or expected type). Try to specify it explicitly.
kyle-query commented 2 years ago

There is a workaround but it's cumbersome:

from typing import TypeVar

T = TypeVar('T')

class assertEqual(Generic[T]):
    def __init__(self, expected: T):
        self.expected = expected
    def __call__(self, actual: T) -> None:
        if self.expected != actual:
            raise Exception()

assertEqual(1)('') # type error
assertEqual(1)(2)  # no type error

There's also another less-general option. According to PEP-483, type widening doesn't occur when the TypeVar has constraints. For example,

T = TypeVar('T', int, str)

def assertEqual(a: T, b: T) -> None:
    if a != b:
        raise Exception()

assertEqual('1', 1)
  # Argument of type Literal[1] cannot be assigned to parameter "b" of "U@f" in function "f".
  #     Type "int" is incompatible with constrained type variable "str"

This only works when you can specified a closed set of type constraints. However, it suggests the possibility of adding a new parameter to the TypeVar constructor to change the way type inference works, per-type variable.

erictraut commented 2 years ago

This situation does come up occasionally (as evidenced by posted questions and bug reports). I don't know if it's common enough to merit a change to the type system.

Mypy's constraint solver uses a join operation to compute the final (widened) type (producing object in your sample above). By contrast, pyright uses a union (producing str | int in your example above). Both are technically correct solutions, but str | int is more precise and is probably less surprising to most developers.

I'll note that TypeScript does widen in some circumstances but not in others. For example, it allows:

assertSomething({ x: 0 }, { y: 0 });

In this case, the solved type of T is { x: number, y?: undefined} | { y: number, x?: undefined }.

KotlinIsland commented 2 years ago

@erictraut basedmypy also uses a union.

DetachHead commented 2 years ago

[ts-toolbelt]() has a NoInfer type that works the same as in kotlin

import {Function} from 'ts-toolbelt'

declare function assertSomething<T>(expected: T, actual: Function.NoInfer<T>): void

// Argument of type '{ y: number; }' is not assignable to parameter of type '{ x: number; }'.
assertSomething({ x: 0 }, { y: 0 });

playground

DetachHead commented 7 months ago

the NoInfer utility type has now been added to typescript