Rust Newtypes

A case against primitives and for newtypes for (almost) everything

2026-06-11

In Rust, "newtypes" are a pattern that wrap some basic type (commonly, primivites like u64, or something like String) in a single-element tuple struct. As an example, for a TCP/UDP port:

struct Port(u16);

Newtypes are zero-cost abstractions, so there will be no runtime performance impact.

In this post, I make the case for using newtypes liberally to increase robustness and correctness of a codebase.

Let's use the TCP port example from above. First, let's see how the approach would look like if we didn't use newtypes. In that case, we'd just use u16 as-is:

let port = 22_u16;

fn connect(port: u16) {
    todo!()
}

This may also be improved somewhat by using a type alias:

type Port = u16;

let port: Port = 22_u16;

fn connect(port: Port) {
    todo!()
}

There is not a big difference between those two. Even with type aliases, you can still just pass a u16 to a function that expects a Port. The type alias is just this: Another name for the same thing.

Using newtypes, the same thing would look like this:

struct Port(u16);

let port = Port(22);

fn connect(port: Port) {
    todo!()
}

The difference is profound: Port is no longer a u16. The inner integer is opaque and cannot be accessed by users.

The whole argument comes down to a single question:

Is a port an integer?

I say: No.

First of all, that would mean that all types that are an integer are interchangeable. But they are not: A port and a user ID are distinct types, even though both may be represented as an integer.

The main risk here is about mix-ups: If both the port and your user ID are u16, then the signature of some function taking both would look like this:

fn foo(port: u16, user_id: u16) {}

And the usage may look like this:

let port = 22_u16;
let user_id = 1234_u16;

foo(user_id, port);

And there you are: Port and user ID are switched, but this is quite hard to notice. Certainly not at the call site itself without taking a close look at the function signature.

With newtypes, this would look a bit different:

fn foo(port: Port, user_id: UserId) {}

let port = Port(22);
let user_id = UserId(1234);

foo(user_id, port);

The compiler comes to your rescue:

  error[E0308]: arguments to this function are incorrect
  --> src/main.rs:10:1
   |
10 | foo(user_id, port);
   | ^^^ -------  ---- expected `UserId`, found `Port`
   |     |
   |     expected `Port`, found `UserId`
   |
note: function defined here
  --> src/main.rs:4:4
   |
 4 | fn foo(port: Port, user_id: UserId) {}
   |    ^^^
help: swap these arguments
   |
10 - foo(user_id, port);
10 + foo(port, user_id);

It even tells you that the arguments have the wrong order!

Secondly, a port may have the shape of an integer, but it's semantics are different from the semantics of an integer. They may overlap, but:

To the first point: If you treat a port as a raw u16, you get all these methods for free. Let's take a look at a few examples:

Same thing with all the arithmetic operations. I don't think that the difference between two ports has any semantic meaning. Or have you ever wondered what "NFS port plus FTP port" is?

At the same time, there are semantics of a port that are not included in u16. These can be nicely implemented on a newtype:

impl Port {
    fn is_privileged(self) -> bool {
        self.0 < 1024
    }
}

These could also be implemented as standalone functions:

fn is_port_privileged(port: Port) -> bool {
    port < 1024
}

But then you'd have the same issues about mix-ups as above. And this becomes really bad when you mix up the usage of your is_privileged() function between a port and a user ...