Proposal: typing.TypeExclude for subtractive/exclusion types

Summary

Add a new TypeExclude to typing for static subtractive types.

Motive

Python typing is good at “addition”, such as typing.Union and type uinon in 3.10), but “subtraction” ability is still incomplete.

Concepts

Grammar & Usage

typing.TypeExclude[T, T1, T2, ...] means “instances of T, except of instances of T1 and T2 and …”.

Compare to type.Union[T1, T2, T3, ...] standing for T1 or T2 or T3 or ..., typing.TypeExclude[T, T1, T2, ...] stands for T - T1 -T2 - ...

For example:

from typing import TypeExclude

class Foo: ...
class Bar1(Foo): ...
class Bar2(Foo): ...
class Bar3(Foo): ...

def baz(t: TypeExclude[Foo, Bar3]) -> None: ...

baz(Foo())     # passed
baz(Bar1())     # passed
baz(Bar2())     # passed
baz(Bar3())     # Type checker error! `Bar3` is excluded from `Foo`

Advantages

  • terse expression:
class Foo: ...
class Bar1(Foo): ...
class Bar2(Foo): ...
class Bar3(Foo): ...
class Bar4(Foo): ...
class Bar5(Foo): ...

# you need to write like this before to exclude `Bar5`
def baz(t: Bar1 | Bar2 | Bar3 | Bar4) -> None: ...

# now you can write like this
from typing import TypeExclude
def baz(t: TypeExclude[Foo, Bar5]) -> None: ...
  • clear semantic: Annotate “excluding types” directly.

Example

Think about we have a base class for all api exceptions ApiException, and several of subclasses of it, such as ApiNotFoundError, EmptyApiError, ApiServerError, etc.
We are now creating a function to handle all api exceptions except for ApiServerError because users cannot solve it.
To select handlable exceptions, we have to write the type hint like this:
exception: ApiNotFoundError | EmptyApiError | <other ApiException except of ApiServerError>
This is too verbose – we only want to express “api exceptions except of ApiServerError”, but we had to write “what is avaliable” instead of “what is not allowed”. With typing.TypeExclude, we can write the type hint like this:
exception: typing.TypeExclude[ApiException, ApiServerError]
It means that "All ApiException instance is allowed, except for instances of ApiServerError.
This is clear and terse, and the more subclasses you have, the more concise TypeExclude is.

Potential Problems

  1. Difficult implementation for type exclusion?
  2. Static or runtime?
  3. Execution overheads?

Anyone has the same feeling? Or any enchancing idea?

Note that these are not the same.

def baz(t: Bar1 | Bar2 | Bar3 | Bar4) -> None: ...

baz(Foo()) # Doesn't work, Foo is not in the Union

# BUT

from typing import TypeExclude
def baz(t: TypeExclude[Foo, Bar5]) -> None: ...

baz(Foo()) # Works

Apart from that, the idea of inversion/exclusion for types has been discussed multiple times before, and it’s been denied most times.

Most of the posts were about a Not[T], which would be hard, if not impossible, to implement in a type system where types are dynamic. See this for more (although he concluds it would be possible, and it’s even simpler for exclusion than for negation, it’s hard). I think similar problems might apply for TypeExclude[T, ...].

What happens, when we e.g. try TypeExclude[T, T]. Sure, type-checkers and runtime could deny it, but who’s to say the types aren’t static?

For some TypeExclude[T1, T2], T1 could bea subtype of T2 too, and what now? E.g. TypeExclude[bool, int]. I suppose this should be rejected by type-checkers and at runtime, but I might be wrong here.

If this were to be implemented, I think the specification of it should be very precise about any edge case that would come to mind. Still it’s an interesting idea to think about.

While I recognise the advantage of such a construct (e.g. to type float but not int), the motivating example:

can be expressed cleanlier, I think:

try:
    ...
except ApiServerError:
    raise
except ApiException as exc:
    handle(exc)

Also, as @JoBe points out, this isn’t trivial at all (for edge cases etc.), although it seems simple at 1st sight.

Thank you very much for your correction and criticism.
I notices the diffirences between Bar1 | Bar2 | Bar3 | Bar4 and TypeExclude[Foo, Bar5], and correct the example code at once.
For refusing of past ideas, I will learn from them and reconsider my idea with more rigorous thinking. For edge cases you mentioned, I really didn’t realize them before, so thank you for pointing out them.
All in all, your criticsm is absolutely valuable and I will improve myself as well as my idea in the future.

The terser approaching is quite good, and I’m applying it now. Furthermore, the critism @JoBe pointed out is also inspiring and I’m now improving my idea according to his critism. Thank for your advice very much!

The edited version doesn’t work now.

Bar5 is a subtype of Foo. Therefore, code like the following would be valid now.

def baz(t: Bar1 | Bar2 | Bar3 | Bar4 | Foo) -> None: ...


baz(Foo()) # Wanted
baz(Bar5()) # Unwanted, but works

Exactly, so can you give offer some suggestions for example corrections?

This is more general than intersection types, and will therefore even more difficult to properly implement. It’s more general because it can be used to define intersection types:

type Intersection[A, B] = TypeExclude[A, TypeExclude[A, B]]

So we’ll first have to “solve” intersection types before we could even start thinking about these proposed semantic difference types.

5 Likes