Idiomatic pattern matching without arity overloading. (Single function vs mutliple)

It’s still very valuable information for future maintainers.

It’s 50/50 and I like your approach but you could also do it like this as you alluded to earlier and I don’t think a lot of information will be lost:

@type message_request :: {:send, String.t()}
@type disconnect_request :: :send_disconnect

@type message_response :: {:push, String.t(), t()}
@type disconnect_response :: {:stop, :normal, t()}

# ...

@impl true
@spec handle_info(message_request() | disconnect_request(), t()) ::
    message_response() | disconnect_response()

Sure, some information is lost – like is it OK to have a message_request in parameters but disconnect_response as a return value is what I’d be a little grumpy about – but expressing types like this still increases readability and you can also put a short comment saying that a message response is only returned when message request is passed and the same for disconnect and others.

Ultimately though, your way is still fine IMO. And I might prefer it in some cases.