· 6 min read ·

Extending Foreign Types: The Coherence Trade-off in Systems Languages

Source: lobsters

Every systems programmer hits the same wall eventually. You are integrating two independent libraries: one provides a type, the other defines an interface, and you need the type to satisfy the interface. Neither library anticipated the other. What you do next is determined entirely by which language you are writing in.

This is the extension problem, and it is where the design philosophies of systems languages diverge most clearly. A 2023 survey of method design across systems languages covers how Rust, Go, Zig, C, C++, and Odin each approach methods as a concept. The survey is thorough on receiver syntax and dispatch mechanics, but the extension question it raises in passing deserves focused treatment, because it is where theoretical design choices translate into daily engineering friction.

The Concrete Scenario

You are building a service that serializes network connections to a structured log format. Your networking library provides a socket address type. Your logging framework requires values to implement a Serialize trait or interface. You own neither library. The question is whether your language lets you express “this pre-existing type implements this pre-existing interface,” and if not, what the workaround costs you.

This is not a contrived example. It describes most cross-library integration work.

Go: Wrapper Types, Every Time

Go’s method system ties methods to their type’s package. You can only define methods on types declared in the same package you are writing. If you need a third-party type to satisfy an interface, you wrap it:

type LoggableAddr struct {
    net.TCPAddr
}

func (a LoggableAddr) MarshalJSON() ([]byte, error) {
    return []byte(`"` + a.String() + `"`), nil
}

Go’s structural interface system compensates partially. Because interface satisfaction is implicit, any type with the right method signatures already satisfies your interface without a declaration. This covers the case where the foreign type happens to already have what you need. It fails the case where you need to add a method the original type lacks.

The wrapper pattern works but carries ergonomic debt. Every call site that handles net.TCPAddr needs an explicit conversion to LoggableAddr. If the interface requires many methods and the wrapped type’s existing methods do not automatically forward through embedding, you write forwarding stubs by hand. Go’s two-word interface representation (type pointer plus data pointer) means every interface call involves indirection regardless, so the wrapper adds allocation cost without adding dispatch complexity. The price is entirely at the API seam, which is where you feel it most.

Rust: The Orphan Rule

Rust’s answer is the orphan rule: you can implement a trait for a type only if you own the trait or the type. The obvious solution to the extension problem is illegal:

// Does not compile. Neither serde::Serialize nor std::net::SocketAddr is local.
impl serde::Serialize for std::net::SocketAddr {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.to_string())
    }
}

The standard response is the newtype pattern: wrap the foreign type in a local struct, then implement whatever traits you need on the wrapper:

struct LoggableAddr(std::net::SocketAddr);

impl serde::Serialize for LoggableAddr {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.0.to_string())
    }
}

This is principled but costly. Every API boundary that passes a SocketAddr must now convert to LoggableAddr. The Deref trait reduces some friction but does not eliminate explicit wrapping at function boundaries. Derive macros like #[derive(Debug, Clone)] do not propagate through newtypes automatically, so you end up re-deriving or writing manual impl blocks.

The serde crate’s response to this friction was the with attribute, an escape hatch that lets you specify serialization behavior via a module:

#[derive(Serialize)]
struct Connection {
    #[serde(serialize_with = "serialize_addr")]
    peer: std::net::SocketAddr,
}

fn serialize_addr<S: serde::Serializer>(
    addr: &std::net::SocketAddr,
    s: S,
) -> Result<S::Ok, S::Error> {
    s.serialize_str(&addr.to_string())
}

The existence of #[serde(with = "...")] is an acknowledgment that the pure newtype approach is too cumbersome for one of the most common integration scenarios in the ecosystem. It maintains soundness while reducing ceremony at specific call sites. It is a practical escape valve for a rule that is correct in principle and painful in practice.

Why the Orphan Rule Exists

The rule prevents a coherence failure that earlier type-class systems encountered. If any crate could implement any trait for any type, two separate crates could both provide impl Serialize for SocketAddr, and a program depending on both would have an unresolvable conflict. The compiler cannot choose between two implementations of the same interface for the same type without applying an arbitrary tiebreaker, and any tiebreaker would produce surprising behavior depending on dependency order.

Haskell encountered exactly this with typeclass instances. GHC emits orphan instance warnings and handles conflicting instances through overlapping instance extensions, each with edge cases that interact in ways that surprise even experienced Haskell programmers. The Rust designers drew a hard line before the ecosystem grew large enough to make the rule retroactively painful to enforce.

The practical benefit is auditable coherence. For any (Trait, Type) pair in a Rust dependency graph, there is at most one implementation anywhere in the compilation. An IDE can unambiguously resolve trait method calls without global disambiguation logic. Security audits can enumerate all implementations of a sensitive trait, such as From<UserInput>, without false negatives from conflicting definitions in transitive dependencies. This matters more as codebases grow, which is why the rule, though frustrating early on, pays off at scale in ways that are hard to attribute but easy to feel when they are absent.

Zig: Abandon the Interface

Zig sidesteps the extension problem by not having interfaces in the traditional sense. Methods are functions declared inside a struct’s namespace, callable with dot syntax as sugar:

const Buffer = struct {
    data: []u8,

    pub fn write(self: *Buffer, p: []const u8) void {
        // ...
    }
};

// list.write(data) is sugar for Buffer.write(&list, data)

Dot-call syntax only works for functions defined inside the struct’s own body. You cannot call an external function as a method on a foreign type using dot notation. The extension problem does not arise because there is no interface contract for a foreign type to fail to implement.

Zig’s polymorphism mechanism is comptime duck typing. A function accepts anytype and the compiler validates the shape at each call site:

fn writeAll(writer: anytype, data: []const u8) !void {
    try writer.writeAll(data);
}

If the type does not have a writeAll method, the compile error appears at the call site. The standard library is developing more structured comptime interface patterns, particularly for I/O abstractions, but the core commitment is no hidden mechanisms, which means no global coherence guarantees either. The contract exists per call site rather than per type.

C++: Free Functions and the ADL Partial Answer

C++ allows free functions and relies on argument-dependent lookup to make them discoverable without full qualification. A serialization function in the same namespace as SocketAddr will be found when you call serialize(addr) in another namespace. This approximates extension without being interface implementation.

C++20 concepts constrain what types a template accepts without requiring explicit implementation declarations. But two translation units can define conflicting template specializations and the result is undefined behavior at link time. The coherence guarantee is absent, and the linker is not required to diagnose the failure. Template-based polymorphism in C++ scales well when you control all the types; it becomes unpredictable when you do not.

What Each Language Gets Right

The extension problem is a stress test for a type system’s assumptions. Go says: wrap the type and be explicit about the seam. Rust says: extend freely within coherence rules, and when the rules are too costly, use an ecosystem-level escape hatch. Zig says there are no interfaces to violate, only shapes that match or do not at each call site. C++ offers several mechanisms with varying coherence guarantees.

None of these is obviously wrong. Go’s wrapper pattern is verbose but transparent; every integration point is a visible type in the codebase. Rust’s orphan rule is restrictive but gives the property that behavior for a type is discoverable without scanning the entire dependency graph. Zig’s comptime approach is genuinely flexible but gives up static contracts that span library boundaries. C++ leaves coherence largely to programmer discipline.

For production systems programming, the coherence guarantee tends to matter more than it initially appears. Integration work across many independent libraries is where most production bugs live. A type system that can tell you “exactly one thing in this entire compilation controls how this type serializes” is worth more friction at the definition site than it costs.

The original survey is worth reading for its side-by-side treatment of receiver syntax and dispatch cost across languages. The extension question is the thread it pulls at the end, and pulling it further reveals that method design is ultimately a set of commitments about who gets to add behavior to a type and what happens when two parties disagree.

Was this interesting?