Some operations make sense for many unrelated types. Integers, timestamps, and domain identifiers can all be ordered. A user event and a billing event can both be encoded for an audit log. None needs to inherit from a shared data superclass, and changing their representations merely to gain one operation would be backward design.

Ad-hoc polymorphism addresses this problem: one operation name has type-specific implementations, and the language selects evidence that an implementation exists. Type classes, traits, and protocols are prominent mechanisms for expressing that evidence. Their terminology and exact semantics vary by language, but they commonly let generic code require behavior without requiring one representation hierarchy.

The thesis is that these mechanisms model capabilities of types, not ancestry of values. Their benefits depend on three design choices that surface quickly in real libraries: who may define a conformance, whether selection is coherent, and whether calls use static specialization or runtime dispatch. Ignoring those choices turns convenient extension into ambiguous behavior.

Begin With Ad-Hoc Rather Than Subtype Polymorphism

Polymorphism is not one feature. Parametric polymorphism implements one algorithm uniformly for any type: identity<A>(value: A) -> A cannot inspect what A is. Subtype polymorphism sends a call through a base interface implemented by runtime objects. Ad-hoc polymorphism selects a type-specific operation, such as equality for InvoiceId or canonical encoding for OrderPlaced.

Mechanism Selection basis Typical strength Typical risk
Parametric generic Same implementation for every admitted type Representation independence Too little behavior available
Overload Statically known argument types Convenient local APIs Resolution rules and closed extension
Subtyping Runtime value’s implementation Heterogeneous collections and late binding Representation coupled to hierarchy
Type class, trait, or protocol constraint Evidence of behavior for a type Retrofitting behavior and generic algorithms Conflicting or incoherent conformances
Pattern match Explicit variant of a closed sum Visible exhaustive behavior Adding variants requires editing consumers

Suppose a logging package needs a stable byte representation for each event. An inheritance-first design might require every event to extend AuditEncodable. That is impossible for types owned by another package and awkward for a timestamp or tuple. A generic function with an unconstrained T has no operation it can call. Reflection can inspect fields but cannot infer domain choices such as redaction, versioning, or canonical order.

An ad-hoc capability states the requirement directly:

text
append : CanonicalAudit<T> => AuditLog -> T -> Result<Offset, AppendError>

append works for a T only when the compiler can find a CanonicalAudit<T> implementation. The event type remains focused on data. The encoding policy can live with the event, the logging package, or an adapter module, subject to the language’s conformance rules.

Treat Type-Class Instances as Evidence

In a type-class formulation, a class declares operations parameterized by a type, and an instance supplies those operations for one concrete type:

haskell
class CanonicalAudit event where
  schemaId :: Proxy event -> SchemaId
  encodeAudit :: event -> Either EncodeError Bytes

instance CanonicalAudit OrderPlaced where
  schemaId _ = SchemaId "commerce.order-placed.v2"
  encodeAudit event = encodeOrderPlaced event

A constrained function can use those operations without knowing the event representation:

haskell
append :: CanonicalAudit event => AuditLog -> event -> IO (Either AppendError Offset)
append log event =
  case encodeAudit event of
    Left problem -> pure (Left (EncodingFailed problem))
    Right bytes -> writeFrame log (schemaId (Proxy @event)) bytes

One useful implementation model is dictionary passing. The compiler can translate the constraint into an explicit record of functions:

text
append(dictionary: CanonicalAudit<T>, log: AuditLog, event: T)

Instance resolution constructs or selects the dictionary at the call site. This model demystifies the feature. A type-class constraint is evidence passed to generic code, even if source syntax makes the evidence implicit.

Instances may be declared separately from both the data type and the class. That enables retroactive extension: an application can teach a library operation about an existing domain type without modifying either original declaration. It also creates the central conflict question. If two packages define different canonical encodings for OrderPlaced, which dictionary should an unqualified call receive?

Type classes often support conditional instances. A list is equatable when its element type is equatable; an optional value can be rendered when its contained value can be rendered. Such rules compose behavior structurally, but recursive or overlapping rules can make resolution slow, ambiguous, or surprising. Keep instance heads and prerequisites simpler than the algorithm they support.

Work Through a Canonical Audit Contract

Canonical encoding is deliberately stricter than display formatting. Every process must produce the same bytes for the same logical event, because schema identifiers, signatures, and replay readers depend on that agreement. Define the capability with an associated error type:

rust
trait CanonicalAudit {
    type EncodeError;

    fn schema_id() -> &'static str;
    fn encode(&self) -> Result<Vec<u8>, Self::EncodeError>;
}

struct OrderPlaced {
    order_id: OrderId,
    amount_minor: i64,
    currency: Currency,
}

impl CanonicalAudit for OrderPlaced {
    type EncodeError = OrderEncodingError;

    fn schema_id() -> &'static str {
        "commerce.order-placed.v2"
    }

    fn encode(&self) -> Result<Vec<u8>, Self::EncodeError> {
        encode_fields_in_order([
            ("order_id", self.order_id.as_bytes()),
            ("amount_minor", decimal_bytes(self.amount_minor)),
            ("currency", self.currency.iso_bytes()),
        ])
    }
}

The generic append operation requires the trait:

rust
fn append<E: CanonicalAudit>(log: &mut AuditLog, event: &E)
    -> Result<Offset, AppendFailure<E::EncodeError>>
{
    let body = event.encode().map_err(AppendFailure::Encode)?;
    let frame = Frame::new(E::schema_id(), body);
    log.write(frame).map_err(AppendFailure::Storage)
}

For OrderPlaced, the compiler selects its implementation and substitutes OrderEncodingError for E::EncodeError. A LoginRejected event can have a different representation and error type while using the same append algorithm. No runtime instanceof chain is needed.

The type does not establish canonicality by itself. The implementation could iterate over an unordered map, include local time-zone formatting, or emit secrets. Semantic laws complete the contract: field order is fixed, integer format is unambiguous, prohibited data is absent, and equal logical events produce equal bytes. Generic code relies on those laws even though the compiler checks only the operation signatures.

This example also shows why multiple instances can be harmful. A compact encoding and a redacted diagnostic encoding are both legitimate, but only one may be canonical. Give the second behavior a distinct named wrapper or capability instead of defining a competing conformance:

text
CanonicalAudit<OrderPlaced>
DiagnosticAudit<Redacted<OrderPlaced>>

Different semantics deserve different types or operation names.

Compare Traits and Protocols Without Flattening Them

Language labels are not interchangeable specifications. Rust traits, Swift protocols, Haskell type classes, Scala traits and givens, and similar constructs differ in conformance placement, resolution, object safety, default methods, specialization, and runtime representation. Still, several recurring shapes are useful to compare.

A trait or protocol is often written from the conforming type’s perspective:

swift
protocol CanonicalAudit {
    associatedtype EncodingFailure: Error
    static var schemaID: String { get }
    func encode() -> Result<[UInt8], EncodingFailure>
}

Generic code constrains a parameter to the protocol. The compiler receives a witness that the concrete type conforms. A protocol extension or trait default method can implement operations derived from a smaller required core. Defaults reduce repetition, but adding a default is a semantic change if existing call sites begin selecting it.

Some trait systems permit retroactive implementation only when either the trait or the data type is local to the current package. This orphan rule prevents unrelated downstream packages from defining globally competing implementations for two foreign declarations. Type-class ecosystems may use module conventions, instance visibility, or coherence rules instead. Protocol ecosystems may allow broad retroactive conformance and warn that future library conformances can collide.

Traits can also serve as mixins or provide inherited method bodies, which is not the same role as a type class. Protocols may describe object interfaces usable through runtime existential containers. Type classes may resolve dictionaries implicitly without creating a subtype relation among values. When evaluating a language feature, ask about its actual selection and representation rules rather than inferring behavior from the name.

Make Coherence a Deliberate Constraint

Coherence means that a well-typed program has one unambiguous meaning for an overloaded operation. If encode(event) silently changes based on import order or an unrelated dependency, separate modules cannot reason locally about output.

Global uniqueness is one coherence strategy: at most one implementation exists for each trait-type pair. Orphan rules make that enforceable across packages. Scoped instances take another approach: several dictionaries may exist, but the caller must bring one into scope or pass it explicitly. Explicit dictionaries maximize control at the cost of call-site noise.

Overlapping instances are attractive for defaults:

text
instance Render<A> where ...             // fallback
instance Render<List<A>> where ...       // more specific

They require a specificity rule and can change meaning when a new instance appears. A library upgrade that adds Render<List<A>> may alter downstream behavior without changing source calls. Prefer non-overlapping rules, named wrappers such as Compact<List<A>>, or explicit strategy values when behavior is contextual.

Coherence also affects security and persistence. Equality determines map keys; ordering determines sorted indexes; canonical encoding determines signatures and replay. These operations should not vary by request locale or whichever plugin loaded first. Contextual behavior such as display formatting is better modeled with an explicit locale or formatter value.

Warning

Do not define a convenient foreign conformance for a foreign type when the upstream owner could plausibly add the same conformance later. A local wrapper gives your semantics a stable owner and avoids a future collision.

Use Associated Types for Functional Relationships

A generic trait parameter and an associated type express different relationships. Compare these shapes:

text
Convert<Source, Target>

trait Iterator {
  type Item
  next : Self -> Option<Item>
}

Convert<Source, Target> can admit several targets for the same source. Iterator.Item says each iterator implementation chooses one element type. The latter models a functional dependency from implementation to item:

IteratorTypeItemType.IteratorType \to ItemType.

Associated types reduce ambiguity in generic signatures. A function consuming an iterator refers to I::Item rather than introducing a separate A and a constraint proving I iterates over A. They are also useful for error types, storage keys, request responses, and other types determined by a conformance.

Choose a type parameter when multiple implementations for different parameter values are meaningful. Choose an associated type when the conformance should determine one answer. This is an API semantics decision, not merely shorter syntax. Changing from one to the other later can affect coherence and inference.

Associated types complicate heterogeneous storage. Two values conforming to CanonicalAudit may have different EncodeError types, so a collection of “any canonical event” needs to erase or unify that difference. A wrapper can map every error into a shared AuditEncodingFailure and store a closure that performs encoding. Type erasure trades static specificity for a stable runtime interface.

Choose Static or Dynamic Dispatch by the Use Case

Generic constraints commonly use static dispatch. The compiler specializes code for a concrete conformance or passes a known witness table. Specialization can inline operations and preserve associated-type information, but it may increase generated code size and compile time.

Dynamic dispatch stores a value behind a trait object, existential, or interface reference and selects methods through a runtime table. This supports plugin boundaries and heterogeneous collections:

text
List<Box<dyn ErasedAuditEvent>>

The erased interface must expose a uniform result, so concrete associated types are hidden or converted. Runtime dispatch adds indirection and restricts operations that require knowing the concrete Self type. These restrictions are often called object-safety or existential usability constraints, though exact rules vary.

Neither dispatch mode is inherently superior. Use static constraints for generic algorithms whose concrete types are known and whose relationships should remain visible. Use dynamic values when types arrive at runtime, independently built plugins participate, or a heterogeneous queue is the actual requirement. Measure performance only after choosing the semantically correct representation; dispatch overhead is rarely meaningful in an operation dominated by encoding or I/O.

Evolve Libraries Without Changing Meaning Accidentally

Adding a required method to a public trait or protocol breaks every existing implementation. A default implementation can preserve source compatibility, but it may be wrong for some types or change dispatch behavior. A safer evolution pattern defines a minimal stable required core and derives convenience methods from it.

Adding a blanket or more-specific implementation can be source-compatible yet semantically breaking through resolution. Removing an implementation breaks constraints far from its declaration. Changing an associated type can alter public return types. Library maintainers should treat the conformance graph as API surface, document ownership, and include downstream-style compile fixtures.

Common design failures include:

  • Creating one giant capability with unrelated methods, forcing meaningless implementations.
  • Using global implicit instances for context-dependent policies such as locale or authorization.
  • Publishing overlapping defaults whose winner depends on subtle resolution rules.
  • Treating default methods as laws even when implementations can override them inconsistently.
  • Erasing associated types too early, then rebuilding safety with casts and tags.
  • Requiring dynamic dispatch for every call when only one boundary needs heterogeneity.
  • Assuming an implementation written by a downstream package will remain conflict-free forever.

Small capabilities compose and evolve better. CanonicalAudit, DisplayAudit, and Redact communicate separate policies more clearly than one EventUtilities trait. Wrapper types let the same underlying value opt into several coherent meanings.

Test Laws, Resolution, and Compatibility

Unit tests for each audit implementation should decode golden fixtures, reject malformed domain values, and prove sensitive fields are absent. Property tests should check determinism and round trips where decoding exists. Run the same law suite for every conformance rather than copying assertions inconsistently.

Compile-time tests cover the polymorphic surface:

  1. append accepts every approved event conformance and rejects an unregistered event.
  2. Associated error types remain precise through the generic return type.
  3. Conflicting implementations fail at the intended ownership boundary.
  4. A local wrapper permits an alternative encoding without changing canonical selection.
  5. Existential erasure converts concrete failures into the documented common error.

Compatibility tests compile small downstream consumers against a candidate library version. They catch added requirements, changed associated types, and resolution changes that unit tests inside the defining package may miss. For persisted bytes, replay a versioned fixture corpus and verify old readers or migration paths before release.

Finally, inspect generated dispatch only when it affects a stated constraint such as binary size or a hot pure loop. The architectural guarantee comes first: generic code receives explicit evidence of a type-specific capability, and coherence keeps that evidence meaningful across modules.

Type classes, traits, and protocols make extension possible without forcing unrelated values into one inheritance tree. Their real design work lies beyond the method list: define semantic laws, assign conformance ownership, choose whether one or several implementations may exist, and preserve the right type information through dispatch. With those decisions explicit, ad-hoc polymorphism becomes a dependable library boundary rather than convenient global magic.