· 6 min read ·

How Ada, Haskell, and Rust Each Solved Distinct Types Before C++26

Source: isocpp

The strong typedef problem in C++ is well understood. using Meters = double creates a synonym, not a new type. Passing Seconds where Meters is expected compiles silently. Every system that handles physical quantities, monetary values, or any domain where unit confusion causes real bugs has been working around this for decades.

What is less discussed is how other languages addressed it, when, and through what mechanisms. A recent proof-of-concept on isocpp shows C++26 reflection generating complete strong typedefs from existing types at compile time. The mechanism is interesting, but the context is more so: this is C++ arriving at a destination that other languages reached by completely different roads, some of them forty years ago.

Ada: The Language-Level Answer (1983)

Ada has first-class distinct types. The new keyword, as used in a type derivation, creates a type that is fundamentally separate from its parent:

type Meters is new Float;
type Seconds is new Float;

X : Meters := 5.0;
Y : Seconds := 3.0;
Z : Meters := X + Y;  -- compile error: incompatible types

Meters and Seconds are distinct. They do not implicitly convert to each other or to Float. They inherit the arithmetic operations of Float, but those operations produce Meters and Seconds respectively, not Float. Explicit conversion is available but must be written.

This is the gold standard, and it was there from the beginning. No libraries, no boilerplate, no manually enumerated capability lists. The language handles it.

C++ committee proposals have attempted something similar. N1706 (2004), N3515 (2012), and P0109 (2015) each proposed opaque typedef syntax. All were rejected or withdrawn. The recurring difficulty was specifying the operation inheritance policy. Ada’s answer works because the language owns the operation semantics for numeric types. In C++, operators are defined by the standard library and by user code, not by the language. The committee could not agree on which operations a new distinct type should carry through, under what conditions, and how to express exceptions to the rule. The design space was genuinely hard, and every proposal punted on at least part of it.

Haskell: Zero-Cost Wrapping with Automatic Delegation

Haskell’s newtype creates a type that is distinct at the type-checker level and erased at runtime:

newtype Meters = Meters { getMeters :: Double } deriving (Show, Eq, Ord)
newtype Seconds = Seconds { getSeconds :: Double } deriving (Show, Eq, Ord)

The deriving clause generates typeclass instances automatically. For standard typeclasses, this is built into the compiler. The real capability is GeneralizedNewtypeDeriving (GND), a GHC extension that lets you derive any typeclass the underlying type implements, by delegating the generated instance to the wrapped type:

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype Meters = Meters Double
  deriving (Eq, Ord, Num, Fractional, Show)

The Num and Fractional instances for Meters are generated by GHC by wrapping Double’s instances. You get +, -, *, /, abs, negate, fromInteger, all of it, without writing anything. The generated code is semantically identical to hand-written code. There is no runtime overhead; the newtype wrapper is completely erased.

This is not an Ada-style language feature. It is automatic code generation triggered by a typeclass constraint. The compiler inspects the interface the underlying type satisfies and generates matching implementations for the new type. The author does not enumerate operations by hand; the delegation is total.

Rust: Clear Semantics, Manual Forwarding

Rust’s newtype pattern is a single-field tuple struct:

struct Meters(f64);
struct Seconds(f64);

These are distinct types. Meters(5.0) + Seconds(3.0) is a compile error because Meters does not implement Add<Seconds>. The type system enforces distinctness without any special syntax.

The problem is forwarding. If you want Meters to support +, you implement Add<Meters> for Meters manually:

use std::ops::Add;

impl Add for Meters {
    type Output = Meters;
    fn add(self, rhs: Meters) -> Meters {
        Meters(self.0 + rhs.0)
    }
}

Every trait you want must be implemented explicitly. The derive attribute handles a fixed list (Clone, Copy, Debug, PartialEq, PartialOrd, Hash), but arithmetic traits require manual implementations. Crates like derive_more extend the derivable set, but they cover a fixed capability list defined by the crate author, not the full interface of the underlying type. There is no Rust equivalent to Haskell’s GND for arbitrary traits.

Scala 3: Scope-Controlled Transparency

Scala 3 introduced opaque type, which takes a third approach: the underlying type is visible only within the defining scope, and the external API is whatever the author explicitly exposes:

object Meters:
  opaque type T = Double
  def apply(v: Double): T = v
  extension (m: T)
    def +(other: T): T = m + other
    def value: Double = m

// Outside Meters:
val a = Meters(5.0)
val b = Meters(3.0)
val c = a + b         // fine, extension method
val d: Double = a     // compile error, T is opaque here

The author controls the entire interface surface explicitly. This avoids the “which operations should carry through” problem by requiring the answer to be written out. It is principled but verbose, similar to Rust in that regard, and it scales poorly when the underlying type has a large interface.

The C++26 Approach

The isocpp article demonstrates a make_strong_typedef utility using C++26 reflection (P2996). The core mechanism is value-based compile-time introspection: the ^^ operator produces a std::meta::info value representing any C++ entity, and consteval {} blocks can inject new declarations using that information:

struct Item { /* name(), price() as methods */ };

struct FoodItem;
struct BookItem;
struct MovieItem;

consteval { 
    make_strong_typedef(^^FoodItem, ^^Item); 
    make_strong_typedef(^^BookItem, ^^Item); 
    make_strong_typedef(^^MovieItem, ^^Item); 
}

void display(FoodItem& i) { std::cout << "Food: " << i.name() << "\n"; }
void display(BookItem& i)  { std::cout << "Book: " << i.name() << "\n"; }

// display(Item{"hello", 1}) -- compile error, no display(Item&) exists

Inside make_strong_typedef, the implementation iterates over the public members of the source type using std::meta::members_of and enqueues forwarding wrappers via std::meta::queue_injection. The injected declarations are semantically native; they participate in overload resolution, ADL, and concept checking exactly as hand-written declarations would.

The philosophical parallel is to Haskell’s GND, not to Ada. The C++ committee’s language-level proposals all failed because they tried to define the operation inheritance policy in the specification itself, as Ada does. C++26 reflection sidesteps that problem by making it a library concern. make_strong_typedef can define whatever forwarding policy the author chooses. Some implementations might forward only non-mutating methods. Others might forward the full public interface. The language provides the mechanism; the library defines the policy. This is why the library route succeeded where the language-level proposals did not.

One technical distinction worth noting: queue_injection works on semantic descriptions rather than raw syntax. You construct a data_member_spec or a function description using std::meta::info values, and the compiler processes those descriptions after the consteval block completes, generating declarations tied to already-resolved semantic context. There are no dangling name lookups, no order ambiguities from interleaved token streams. The injected code is not a macro expansion or a string paste; it is a native declaration generated from a semantic specification.

The article notes that queue_injection as used in the proof-of-concept required the EDG experimental compiler, since that specific injection mechanism was not fully integrated into C++26 as shipped. Without it, you fall back to a two-stage build where a code generation tool produces forwarding wrappers in a separate pass, reintroducing the build system friction that in-compiler injection was designed to avoid.

What Is Still Incomplete

Aggregate initialization does not work automatically. A strong typedef wrapping a struct with public data members gets a constructor that takes the underlying struct, not one that mirrors the struct’s aggregate syntax. For types where users expect {field1, field2} initialization, this is a real gap.

Full constructor forwarding depends on function parameter reflection, which was deferred from P2996 and is targeted for C++29. Until then, forwarding constructors with complex signatures or default arguments requires manual handling.

std::hash specialization, structured binding support, and range/iterator forwarding are each solvable but require explicit handling in the make_strong_typedef implementation. A production-quality version is more complex than the proof-of-concept.

These gaps do not change the direction. C++ is arriving at automatic interface forwarding for distinct types through the library route: roughly four decades after Ada offered it as a language feature and over a decade after Haskell’s GND made it available through typeclass delegation. The mechanism is different from both. The destination is the same.

Was this interesting?