Understanding Structural vs Nominal Typing in Rust

Discover the differences between structural and nominal typing in Rust, using examples to illustrate how Rust's type system impacts programming safety and flexibility. Compare Rust's approach to typing with Elm's, Roc's, and TypeScript's type systems, highlighting the unique aspects of each. This post offers an in-depth exploration of Rust's type system, equipping developers with insights into how different typing strategies can influence code design.

Structural vs Nominal Typing

Structural and nominal typing represent two approaches to type systems in programming languages, shaping how compatibility and equivalence between types are determined.

Structural typing is characterized by its emphasis on the actual structure or definition of a type. In this approach, two types are considered equivalent or compatible if their structures match, regardless of their names or where they are declared. This method is akin to “duck typing,” where the focus is on what the type can do (if it quacks like a duck, it’s treated as a duck), rather than what it is explicitly named.

In contrast, nominal typing relies on explicit declarations or the names of the types to establish compatibility and equivalence. In a nominative system, types are considered equivalent if they have the same name. Nominal typing is used to enforce a stricter relationship between types, ensuring that only those explicitly declared to be related are considered compatible or equivalent.

These two systems offer different trade-offs. Structural typing does not require the explicit declaration of new types, which can often be more ergonomic for the developer, especially in cases where it is a one-off type. On the other hand, nominal typing offers more control and safety, ensuring that types are only considered equivalent or compatible if they are intended to be so, thereby reducing the chance of unintentional behavior.

As we explore how languages like Rust, TypeScript, Elm, and Roc adopt these systems, we’ll see how each approach reflects the language’s overall philosophy and goals in design, safety, and flexibility.

Structural Typing through Tuples in Rust

In Rust, tuples are an example of structural typing within a language that generally favors nominal typing. Tuples allow the grouping of different types without requiring explicit type declarations. This feature can lead to more concise code, particularly in scenarios that require grouping related but different types of data for temporary use.

Consider a function that needs to return two pieces of information, such as a user’s name and age. Using a tuple, Rust allows for a straightforward and type-safe way to achieve this without defining a separate type:

fn get_user_details() -> (&'static str, u32) {
    ("Alice", 30) // Returns a tuple containing a name and age
}

Here, the tuple (&'static str, u32) represents a composite type of a string slice and an unsigned 32-bit integer without the overhead of needing to create an additional type declaration. The return value can then be easily destructured or accessed by its indices.

However, the flexibility of tuples comes with potential drawbacks, especially when the semantic meaning of the data is important. The structural typing of tuples means the compiler checks the type structure for compatibility, which can lead to unintended consequences:

fn calculate_distance(coord1: (f64, f64), coord2: (f64, f64)) -> f64 {
    // Implementation to calculate distance between two coordinates
}

// Coordinates for two points
let point_a = (40.7128, -74.0060); // New York City (latitude, longitude)
let point_b = (34.0522, -118.2437); // Los Angeles (latitude, longitude)

// Intended to represent a book's width and height, but structurally identical to coordinates
let book_dimensions = (8.5, 11.0); // (width, height)

// A call that's accidentally correct but semantically incorrect
let distance = calculate_distance(point_a, book_dimensions); // Oops!

In this example, calculate_distance is meant for geographical coordinates, but the structural compatibility of tuples allows book_dimensions, representing width and height, to be passed as an argument. This situation illustrates a pitfall where structural typing’s flexibility could lead to semantic errors, as the compiler cannot infer the intended use of the data based solely on its structure.

Nominal Typing in Rust: Structs and Enums

To avoid the ambiguity issue demonstrated with tuples, Rust’s nominal typing system allows for the creation of structs that explicitly name and define the purpose of the data being grouped together. This clear definition ensures that only semantically correct data types are passed to functions, eliminating the risk of accidental misuse:

struct Coordinates {
    latitude: f64,
    longitude: f64,
}

struct Dimensions {
    width: f64,
    height: f64,
}

fn calculate_distance(coord1: Coordinates, coord2: Coordinates) -> f64 {
    // Implementation for calculating distance
}

// Usage
let point_a = Coordinates { latitude: 40.7128, longitude: -74.0060 };
let point_b = Coordinates { latitude: 34.0522, longitude: -118.2437 };
let book = Dimensions { width: 8.5, height: 11.0 };

// This now results in a compile-time error, preventing misuse:
// let distance = calculate_distance(point_a, book); // Error!

By defining Coordinates and Dimensions as structs, Rust enforces nominal typing’s requirement for explicit type compatibility, ensuring that only the intended Coordinates instances can be passed to calculate_distance, thereby eliminating the semantic ambiguity present with tuples.

It’s worth noting that Rust also provides a nominal alternative to tuples, known as tuple structs. Tuple structs combine the nominal typing advantages with the simplicity and conciseness of tuples. Refactoring our example to use tuple structs would look like this:

struct Coordinates(f64, f64);
struct Dimensions(f64, f64);

let point_a = Coordinates(40.7128, -74.0060); // Using the tuple struct for coordinates
let point_b = Coordinates(34.0522, -118.2437); // Same here

This approach keeps the distinct, named types for nominal typing while adopting the straightforward, tuple-like syntax for their construction and use.

Enhancing Scalar Value Safety

Nominal typing also allows distinguishing between scalar values that are otherwise of the same base type but serve different purposes. Wrapping scalar values in structs allows for the creation of distinct types, such as Meter and Yard, which can make functions like meter_to_yard safer by enforcing type correctness at compile time:

struct Meter(i32);
struct Yard(i32);

fn meter_to_yard(m: Meter) -> Yard {
    Yard((m.0 as f64 * 1.09361) as i32)
}

// Example usage
let length_in_meters = Meter(100);
let length_in_yards = meter_to_yard(length_in_meters);

Similarly, for IDs that may be represented by the same underlying type (such as integers), wrapping them in distinct structs ensures that they cannot be mistakenly interchanged:

struct UserId(i32);
struct DeviceId(i32);

// This makes it impossible to mistakenly use one for the other:
// let user = UserId(1);
// let device = DeviceId(1);
// Function expecting UserId cannot be called with DeviceId and vice versa.

Enums

In Rust, enums serve as the language’s approach to union types. Like structs, enums in Rust are nominal, meaning each enum is considered a distinct type based on its name and declaration, not just its structure or the variants it contains.

Read more about enum as union types (or sum types) in our post about Algebraic Data Types in Rust.

Let’s illustrate the nominal nature of enums with a simple example: Consider two enums that, while structurally identical, are defined separately:

enum MovementDirection { Up, Down, Left, Right }
enum ScrollDirection { Up, Down, Left, Right }

Despite having the exact same variants, MovementDirection and ScrollDirection are distinct types because they are nominally typed. As a result, it’s illegal to assign a value of one enum type to a variable of the other enum type:

let move_direction = MovementDirection::Up;
// let scroll_direction: ScrollDirection = move_direction; // This line will cause a compile error

Attempting to assign move_direction, which is of type MovementDirection, to scroll_direction, which is expected to be of type ScrollDirection, results in a compile-time error.

The Role of Type Alias in Rust’s Typing System

Type aliases allow to create alternative names for existing types. However, type aliases do not introduce new types in terms of type safety. Instead, they simply provide a new label for an existing type, without adding any additional compile-time type checking.

Consider the use of type aliases to distinguish between user IDs and device IDs, both of which are represented as i32 values:

type UserId = i32;
type DeviceId = i32;

While these aliases help clarify the code by indicating the intended use of each i32 value, they do not enforce any type safety at the compiler level. This means that it’s perfectly legal to assign a UserId to a DeviceId and vice versa, because, from the compiler’s perspective, both are essentially i32 values:

let user_id: UserId = 10;
let device_id: DeviceId = 20;

// The following assignments are legal because UserId and DeviceId are both i32.
let another_user_id: UserId = device_id;
let another_device_id: DeviceId = user_id;

While type aliases can make code more readable and expressive by providing meaningful names to types, they do not contribute to Rust’s type safety mechanisms

Adding Structural Typing to Structs with Macros (Structx Crate)

In Rust’s type system, there exists a slight asymmetry concerning structural typing, particularly noticeable when dealing with record-like struct types. While Rust provides robust support for both nominal and structural typing for unit-like types and tuple-like types, it notably lacks a built-in, structural type option for defining record-like types (structs with named fields).

The following table highlights this asymmetry within Rust’s type system:

NominalStructural
Unit-likeYes, struct T;Yes, ()
Tuple-likeYes, struct T(A, B);Yes, (A, B)
Record-likeYes, struct T { a: A, b: B }No, see structx
Union-likeYes, enum T { A(B), C(D) }No

Note that, we unapologetic stole this table from the structx README.

This situation creates a dilemma: in scenarios where declaration of a nominal type is too involved — such when returning multiple values from a function using a one-off type — the absence of structural records might nudge the developer to opt for tuples over records due to convenience, even when records would be the more semantically appropriate choice. This issue prompted an effort to bridge this gap in Rust’s type system through a RFC proposal for Structural Records. However, this proposal has since been closed, indicating that the introduction of structural records into Rust may not happen soon.

Rust’s macros system, however, offers the powerful possibility to extend the language with custom features, without the need to wait until they become part of the language standard. Thus, the structx crate was developed, offering the ability to define structural records on the fly, similar to tuples but with the added advantage of named fields (as proposed by the previously mentioned RFC).

The syntax provided by the structx crate for defining and using these “on-the-fly” records is as follows:

fn foo(x: i32, y: i32) -> Structx!{ x: i32, y: i32 } {
    structx!{ x, y: y+1 }
}

With structx, developers can dynamically create structured records, combining the flexibility and convenience of structural typing with the explicitness of named fields.

Furthermore, it’s also critical to acknowledge Rust’s absence of support for structural union types. This gap becomes particularly notable when comparing Rust to other programming languages, such as TypeScript, Elm, or Roc, which offer more extensive structural typing capabilities. Rust’s design choices, prioritizing safety, and explicit declarations.

Exploring structural typing in languages that include structural union types or offer more lenient record definitions will broaden our understanding of type systems. It will illustrate the varied impacts these systems have on the developer experience and the expressiveness of code.

Elm’s Structural Typing: Records and Open Records

In Elm, records are structurally typed and have the capability to be open, meaning that functions expecting a certain record can also accept larger records with additional fields. This feature is incredibly useful for creating functions that operate on a subset of a record’s fields, enhancing code reuse and composability.

For example, consider a function that requires a record with a name field:

type alias Person = { name : String, age : Int }

greet : { a | name : String } -> String
greet record =
    "Hello, " ++ record.name

Here, greet is designed to work with any record as long as it has a name field. You can pass it a Person record, or any larger record with more fields, and it will still work:

alice : Person
alice = { name = "Alice", age = 30 }

message : String
message = greet alice -- "Hello, Alice"

Open records are indicated by the syntax { a | name : String }, where a represents an unspecified set of additional fields. This means the function can accept records with a name field plus any number of other fields, offering significant flexibility.

But note that this flexibility, due to the structural typing of records, is not always desirable: For example, consider the following record definitions:

type alias Person = { name : String, age : Int }
type alias Employee = { name : String, age : Int }

Both Person and Employee are treated as the same type by the Elm compiler because their structures are identical. To differentiate these types in a nominal way, you must use custom types, which explicitly define distinct types:

type Person = Person { name : String, age : Int }
type Employee = Employee { name : String, age : Int }

By wrapping the record inside a custom type, Person and Employee become nominally distinct, allowing the type checker to differentiate between them based on their names, not just their structure.

Elm allows the creation of nominal types from structural types through the use of custom types but does not support structural union types; custom types are necessary to declare any form of union type. This contrasts with Roc, an emerging language heavily inspired by Elm, which allows the ad hoc creation of structural union types, called “tags”, offering a similar level of flexibility as defining structural records in Elm.

Roc’s Structural Tagged Unions and Opaque Types

Roc, a language still in development and drawing inspiration from Elm, introduces an intriguing approach to handling types with its structural tagged unions and support for opaque types. Similar to enums in Rust or custom types in Elm, Roc’s so-called tags allow for the representation of values that can belong to one of several categories, but with a more flexible, on-the-fly creation mechanism.

This means you can make up tags like Red, Yellow, or Green directly in your code to represent distinct values without declaring them first:

stoplightColor =
    if something > 100 then
        Red
    else if something > 0 then
        Yellow
    else if something == 0 then
        Green
    else -- tags also support payloads
        Custom "some other color"

Here, stoplightColor can assume one of four tags: Red, Yellow, Green, or Custom. The beauty of Roc’s system is that these tags are compared by their structural identity, such that Red == Red is true, just as 42 == 42. They also support pattern matching:

stoplightToStr =
    when stoplightColor is
        Red -> "red"
        Green | Yellow -> "not red"
        Custom description -> description

And, similar to Elm, if the additional safety of a nominal type is needed Roc supports the creation of a so-called Opaque Type using the := operator:

Color := [Red, Green, Blue]

TypeScript: Dominated by Structural Typing

TypeScript operates primarily under a structural type system, with an interesting exception to note: The enum type. More on this later.

First, to understand the nuances of TypeScript’s structural type system, let’s revisit the UserId vs DeviceId example from before. Even though both are intended to represent different concepts (a user’s ID and a device’s ID), they can be structurally identical:

type UserId = { id: number };
type DeviceId = { id: number };

In TypeScript’s structural type system, these types are interchangeable because their structures (both have an id property of type number) are the same. This means you can assign a UserId to a DeviceId and vice versa without any error:

let userId: UserId = { id: 1 };
let deviceId: DeviceId = { id: 2 };

userId = deviceId; // Works in TypeScript's structural type system
deviceId = userId; // Also works

If we introduce a new type that includes all members of UserId and DeviceId, plus an additional field, it still retains compatibility due to structural typing:

type ExtendedId = { id: number; extra: string };

let extendedId: ExtendedId = { id: 3, extra: "extra info" };

userId = extendedId; // Also works because userId's structure is contained within ExtendedId
extendedId = userId; // Error: Property 'extra' is missing in type 'UserId' but required in type 'ExtendedId'.

Enums as an Exception

Despite TypeScript’s having a predominantly structural typing system, enums in TypeScript behave nominally. This means that even if two enums have the same structure, they are treated as distinct types and cannot be interchanged:

enum MovementDirection { Up, Down, Left, Right }
enum ScrollDirection { Up, Down, Left, Right }

const scrollDirection: ScrollDirection = MovementDirection.Up;
// Error: Type 'MovementDirection.Up' is not assignable to type 'ScrollDirection'.

Conclusion

The exploration of typing systems across different programming languages — Rust, Elm, Roc, and TypeScript — reveals a fascinating spectrum of approaches to managing and leveraging types in software development. Each of these languages adopts a distinct approach to structural and nominal typing, reflecting it’s their design principles and the needs of its user base.

Rust’s commitment to safety and performance underpins its preference for nominal typing, with structs and enums ensuring type correctness and clarity. However, Rust’s openness to structural typing through tuples and the innovative use of external crates like structx highlight the language’s flexibility.

Elm, with its focus on frontend development, adopts structural typing for records, streamlining data manipulation and enhancing developer productivity. The language’s support for type aliases further underscores its emphasis on readability and maintainability, crucial qualities for building complex user interfaces.

Roc extends the idea of structural typing to tagged unions and introduces opaque types through the := operator. This innovation blends the flexibility of structural typing with the safety benefits of nominal typing, presenting a novel approach that could influence future language designs.

TypeScript stands out for its predominant use of structural typing, fostering a dynamic and flexible ecosystem that eases the integration of diverse libraries and frameworks. The nominal typing of enums within TypeScript serves as a reminder of the choices language designers make to balance flexibility with clarity.

Through these languages, we observe a rich dialogue between structural and nominal typing paradigms, each offering distinct benefits and trade-offs. Rust’s system emphasizes safety and explicit relationships, Elm’s eases data flow in frontend applications, Roc’s enhances type abstraction, and TypeScript’s supports flexible integration in a dynamic ecosystem. This comparative analysis illuminates nuanced ways in which languages can employ typing systems to meet the demands of software development, balancing the need for both flexibility and safety.

Recent Posts

» View all