Class-like syntax for union type aliases and natural docstrings

In many type aliases, a union is the outermost operation.

The recent PEP 846 (docstrings for type aliases) reminded me of an old idea of mine. That PEP proposes to attach a string statement to the preceding type statement, because the current type statement (unlike class) has no natural place for a docstring. I propose the following class-like syntax that takes the union of all its members.

For example, instead of:

type Timeout = int | float | None
"""Maximum wait in seconds; None waits indefinitely."""

we could have:

type Timeout:
    """Maximum wait in seconds."""
    int
    float
    None  # waits indefinitely

That is the whole proposal.

One more example from typeshed’s inspect.pyi, which today looks like:

_SourceObjectType: TypeAlias = (
    ModuleType | type[Any] | MethodType | FunctionType | TracebackType | FrameType | CodeType | Callable[..., Any]
)

versus the proposed form:

type SourceObject:
    """Objects whose source inspect.getsource() and friends can retrieve."""
    ModuleType
    type[Any]
    MethodType
    FunctionType
    TracebackType
    FrameType
    CodeType
    Callable[..., Any]

Why I think this is worth considering:

  1. Union is often the outermost structure of a type alias. In that case, indentation can express this structure directly and cleanly.
  2. Long unions are common. The example is not an outlier: typeshed’s stdlib stubs contain 55 union aliases with four or more members (up to an extreme of 16, as in _Marshallable).
  3. Documentation gets a natural home. A docstring at the top of a suite feels Pythonic.
4 Likes

You can separate across lines using parentheses:

_SourceObjectType: TypeAlias = (
    ModuleType
    | type[Any]
    | MethodType
    | FunctionType
    | TracebackType
    | FrameType
    | CodeType
    | Callable[..., Any]
)

I think that form is fine and natural.

That said, I agree that type alias statements should support docstrings, but that’s already an upcoming PEP, as far as I know.

5 Likes
2 Likes

Indeed.

Python used to be a language to “fint into our brains” - while it has since grown, I don’t see the need to include new syntax for a feature that is deemded optional - less so when it is covered by regular use of the existing syntax and whitespace.

The re-use of `type` for overloading it with yet a 3rd different meaning if something I still consider as harmful in itself.

I guess it does not need to be a union, does it?

type Timeout:
    """Maximum wait in seconds."""
    float

Of course, it could also contain a single type, exactly as in @sinoroc example.

In that case, the main role of the class-like form would simply be to provide an alternative spelling of a TypeAlias with a natural place for a docstring, rather than to express a union.

To elaborate a bit on this, with PEP 846 we would indeed be able to write

type SourceObject = (
    ModuleType
    | type[Any]
    | MethodType
    | FunctionType
    | TracebackType
    | FrameType
    | CodeType
    | Callable[..., Any]
)
"""Objects whose source inspect.getsource() and friends can retrieve."""

which to me looks like a very reasonable alternative to the proposed

There’s now also a discourse thread for this PEP draft: PEP 846: Docstrings for Type Aliases

2 Likes

Conditional memberships are possible too:

type ConvertibleToInt:
    """Anything that can be passed to the int constructor."""
    str
    ReadableBuffer
    SupportsInt
    SupportsIndex
    if sys.version_info < (3, 14):
        SupportsTrunc

instead of

if sys.version_info >= (3, 14):
    ConvertibleToInt: TypeAlias = str | ReadableBuffer | SupportsInt | SupportsIndex
else:
    ConvertibleToInt: TypeAlias = str | ReadableBuffer | SupportsInt | SupportsIndex | SupportsTrunc

source: python typeshed.

2 Likes

Wouldn’t

type ConvertibleToInt = (
    str |
    ReadableBuffer |
    SupportsInt |
    SupportsIndex |
    (SupportsTrunc if sys.version_info < (3, 14) else Never)
)
"""Anything that can be passed to the int constructor."""

work for that too?

if statements aren’t supported in type aliases AFAIK, but you can extend the type with some duplication:

type ConvertibleToInt = str | ReadableBuffer | SupportsInt | SupportsIndex
if sys.version_info < (3, 14):
    type ConvertibleToInt = ConvertibleToInt | SupportsTrunc

Might be nice to add __ior__ so one could do type ConvertibleToInt |= SupportsTrunc instead.

2 Likes

I think ConvertibleToInt becomes a self-referencing type in 3.14 this way.
‘Classic’ TypeAlias does what you intend, though.

I assumed it would self-reference, but Ty seems to show it as flattened, not sure if this behavior is specified somewhere though.

@Monarch, you wrote in another thread:

Tangential: I really dislike type name = value. I much prefer Python’s name: type = value (same for Rust, TypeScript, Kotlin, etc)

I’d be curious what you think of my idea. I also feel that type name = value is a bit c++ish rather than pythonic :wink:

That was in the context of the thread proposing C-like variable annotation syntax such as int foo = 10. That’s what I meant when I said I disliked type name = value.

I later clarified this because someone else had the same confusion, evidently my phrasing was pretty poor!

As for the type statement itself, I have no issues with the current syntax. It also seems to be the convention in several other modern languages:

Rust:       type Result<T> = ...;
TypeScript: type Result<T> = ...;
Go:         type Result[T any] = ...;
Python:     type Result[T] = ...
2 Likes

I assume this won’t support an empty type statement?

type Empty:
    """Type with no members"""
1 Like

Thank you @ThomasP for the suggestion, I didn’t think about it :nerd_face:

Yes, that would be equivalent to Never

Without docstring that could be

type Empty:
    pass

# Empty == typing.Never
1 Like