I should be able to add new types of messages AND/OR new types of handlers without affecting the existing messages or handlers (i.e., loose coupling).
So, what you might be running into here is known in the CS community as the "expression problem". The gist of it is, there are two main ways to approach data abstraction, with different tradeoffs.
One is message passing in the OOP sense, where it's hard to extend the interface (add new types of messages, add new abstract operations), but easy to add new implementations (easy to add new kinds of handlers, new derived classes). The reason is, the implementations are all tightly coupled to the interface (the set of abstract operations), so if you change it, and you want to make sure that there are no surprises in clients that use it polymorphically, you have to track down all the implementations, and add support for the new method/message. But adding a new derivative doesn't affect the interface, or the other implementations.
The other is the the abstract data type approach (in the sense used by William R. Cook [1]), where it's hard to add new concrete types, but easy to add support for new abstract operations (over that set of concrete types). If/switch/cast, and the traditional visitor pattern, the C++ std::visit, and algebraic data types common in functional programming fall under this category. Here, the abstract type represents a finite set of concrete types (that may be just a set of mutually exclusive data structures, or may combined into various structures), and the client code works by calling operations on the abstract type. The implementations of the operations are aware and internally coupled to the concrete types, though, and generally have to support all variants. So if you add a new concrete type (like a new Element in the Visitor pattern), you generally have to update all the implemented operations, but adding a new operation (a new Visitor, or a new function that internally dispatches on the concrete type) doesn't affect anything else.
You'd choose one or the other based on your expectation of what's likelier to change more frequently - the set of handlers/implementations (go for OOP), or the set of operations (go for an ADT), or conversely based on what's more stable (the core abstraction that everything depends on should be the more stable thing).
The book Structure and Interpretation of Computer Programs covers these two approaches as well, but it also suggests a third possible approach that they call "data-directed programming". The idea is that you can do a sort of a 2D table lookup, where the set of operations forms one axis, and the set of concrete types the other - you'd then dispatch to a concrete implementation based on those two keys. So you'd either have to go outside the type system and represent the types and the operations as data, or somehow arm-twist the compiler into doing this for you, if that is at all possible with the language you're using. So while this can work, and gives you a lot of flexibility in one sense, it's also likely to be unwieldy to use/maintain, and shouldn't be your go-to solution, IMO.
P.S. I'm not 100% sure if I understood your description well, but depending on what you're actually trying to do, it might be that the problem is that you're attributing the wrong role to your messages. For example, in the visitor pattern, instead of representing them through the IElement hierarchy, perhaps they should be a part of the IVisitor hierarchy (remember, it's the concrete visitors that represent concrete implementations of operations, and their type represents the abstract operation itself - i.e. your message). So, something like (pseudocode):
class IVisitor{public: virtual void visit(DataStructA&) { /* ... */ } virtual void visit(DataStructB&) { /* ... */ }};class MessageA : public IVisitor { /* ... */ };class MessageB : public IVisitor { /* ... */ };class MessageC : public IVisitor { /* ... */ };class IElement{public: virtual void accept(IVisitor&) = 0;};class DataStructA: public IElement{public: void accept(IVisitor& v) override { v.visit(*this); }};class DataStructB: public IElement { /* ... */ };...// client code:// -----------------------------------------------element.accept(message); // you can also read it as: element.do(operation)
Here, you can add new types of messages and handlers just by creating a new IVisitor derivative, but the tradeoff is that it's tricky to add new kinds of data structures that they can handle. I chose the Visitor pattern as an example, but you can do this with if/switch, or with std::visit, the important part is the conceptual switch.