A ternary operator for typed Python

Hello, this is my first post here, so I’m sorry if this has already been discussed. I don’t see any PEPs related to this and my search of this forum and GitHub came back empty.

Proposal would be to add the following types:

If - for ternary operations

Equals for comparing straight equality ==. Seems like pyright does this but mypy doesn’t.

IsInstance to see if an object is a type.

Say I want type safety for a divisor I’m passing around, and I want to make sure someone doesn’t type zero before runtime.

TypeScript has the following:

type NotZero<N extends number> = N extends 0 ? never : N;
const num: NotZero<1> = 1;
const bad: NotZero<0> = 0;

The proposal here is to introduce a Python-equivalent.

type NonZero[N: number] = If[Equals[N, Literal[0]], Never, N]

This will help type checkers with branching logic, so instead of a Union type:

def get_animal_noise(pet: Dog|Cat) -> Literal['woof', 'meow']: ...

if get_animal_noise(Dog()) == "meow": ...  # no error

You can provide more helpful type instructions:

def get_animal_noise[A: Dog|Cat](pet: A) -> If[
  IsInstance[A, Dog], Literal['woof'], Literal['meow']
]: ...

if get_animal_noise(Dog()) == "meow": ... # Pylance(reportUnnecessaryComparison)
1 Like

It might be better if we find more restricted ways to express these ideas? For example, could we instead express NonZero as the difference between a numeric type and 0? E.g. NonZero = int - Literal[0].

I think the get_animal_noise case could be written today with overloads in a more Pythonic manner.

More generally, I hope that if this area is explored, we end up with something slightly nicer on the eyes readability-wise – both the TypeScript and proposed If[] syntaxes don’t look great to me. Type annotations are arbitrary expressions, so Never if N == 0 else N would seem to be more Pythonic.

A

3 Likes

Thanks for the response! I agree with the improved readability and pythonic nature of if ... else, but it appears to be contentious, from my perspective, so I decided to leave it out of the original post.

Regarding Equals or ==, I think the syntax is pretty clean, and could be restricted solely to scalar types that could be represented by Literal. This is just personal preference obviously, but to me, int - Literal[0] looks less intuitive.

As for the get_animal_noise example, @overloadoverload is very useful, but not universally useful in my experience. Get to 10+ overloads, and you end up with lots of difficult to debug overlaps. The static type checkers also often provide incredibly unhelpful errors when that happens. Conditional returns are explicit in a way that @overload cannot be.

I should add that they also aren’t mutually exclusive. @overload is more useful for documentation purposes, but conditioanl returns would be explicit logical descriptors for the underlying function that would reduce the need to fall back to All or some needlessly general type in the implementation. That could help writers troubleshoot why people creating overloads aren’t working. Depending on implementation, it could also help provide more useful error descriptions to the end user than “did not match overload… closest match was overload 9”

Note that this is more general than typing.TypeIs. For instance, the following two function signatures would be equivalent:

def is_instance[T](obj: object, cls: type[T], /) -> TypeIs[T]: ...

def is_instance[S, T](obj: S, cls: type[T], /) -> If[
    IsInstance[S, T],
    Literal[True],
    Literal[False],
]: ...

It could also be used to do arithmetic in the static type system, for example by representing the natural numbers as the length of integer tuples:

type Number = tuple[int, ...]
type Zero = tuple[()]
type Add1[N: Number] = tuple[*N, int]
type Sub1[N: Add1[Number], _M: Number = Number] = If[
    IsInstance[N, Add1[_M]],  # type-narrowing
    _M,
    Never,
]

type Add[A: Number, B: Number] = If[
    IsInstance[B, Zero],
    A,
    If[
        IsInstance[B, Add1[Number]],
        Add[Add1[A], Sub1[B]],
        Never,
    ],
]

# -snip-

a1: Add1[Zero] = ...
assert_type(a1, tuple[int])

a1a1: Add1[Add1[Zero]] = ...
assert_type(a1a1, tuple[int, int])

a1s1: Sub1[Add1[Zero]] = ...
assert_type(a1s1, tuple[()])

a1a1s1: Sub1[Add1[Add1[Zero]]] = ...
assert_type(a1a1s1, tuple[int])

However, this would require some generic variant of type-narrowing in Sub1.


As you can see in the example above, nesting Ifs isn’t the best for readability, but will probably be used quite often, as there’s no elif analogue.
So perhaps something more “variadic”, for example Match[T, [Case[A1, R1], Case[A2, R2]], could be worth considering?

2 Likes