Skip to main contentType Aliases and Newtype | IoT Worker

Type Aliases and Newtype

C uses typedef heavily:

typedef uint32_t DeviceId;
typedef uint8_t Byte;
typedef int (*compare_fn)(const void *a, const void *b);

It reduces repetition, hides complex declarations, and gives a business name to a type. But typedef does not always create a stronger type boundary. Often it is just another name for the original type.

Rust has two common tools:

type DeviceId = u32;      // type alias, not a new type
struct DeviceId(u32);     // newtype, a distinct type

The important split is simple: use an alias to simplify names; use a newtype when you want the compiler to stop accidental mixing.

type Is Only an Alias

In C:

typedef uint32_t DeviceId;

void open_device(DeviceId id);

The direct Rust equivalent is type:

type DeviceId = u32;

fn open_device(id: DeviceId) {
    println!("open {id}");
}

A caller can pass a u32:

let raw: u32 = 7;
open_device(raw);

DeviceId is only another name for u32. It makes the signature easier to read, but it does not make DeviceId and u32 different types.

This is close to many C typedef uses: the name changes, the underlying type does not.

Aliases Are Good for Reducing Repetition

type is useful for naming long or repeated types.

For callbacks:

type Handler = fn(event: Event) -> Result<(), Error>;

For a common result shape:

type ParseResult<T> = Result<T, ParseError>;

fn parse_len(data: &[u8]) -> ParseResult<u16> {
    if data.len() < 2 {
        return Err(ParseError::TooShort);
    }

    Ok(u16::from_be_bytes([data[0], data[1]]))
}

ParseResult<T> does not create a new return type. It just hides the repeated Result<T, ParseError> spelling.

Aliases are useful when you want to:

  • shorten complex types
  • standardize an error type
  • make API signatures easier to read
  • name FFI callbacks, function pointers, or closure-like shapes

Aliases Do Not Prevent Mixed Arguments

If two aliases are both u32, they remain interchangeable:

type DeviceId = u32;
type UserId = u32;

fn read_device(id: DeviceId) {
    println!("device {id}");
}

let user: UserId = 100;
read_device(user);

This compiles because DeviceId and UserId are both just u32.

C has the same kind of issue:

typedef uint32_t DeviceId;
typedef uint32_t UserId;

void read_device(DeviceId id);

UserId user = 100;
read_device(user);

If the domain says DeviceId and UserId must not mix, an alias is not enough.

newtype Creates a Distinct Type

Rust commonly uses a tuple struct as a newtype:

struct DeviceId(u32);
struct UserId(u32);

Now they are different types:

fn read_device(id: DeviceId) {
    println!("device {}", id.0);
}

let user = UserId(100);
// read_device(user); // type mismatch

The caller must pass a DeviceId:

let device = DeviceId(7);
read_device(device);

That is the value of newtype: usually no runtime cost, but an extra compile-time domain boundary.

Add Methods to a Newtype

A bare tuple struct works, but exposing .0 everywhere leaks the wrapper. A common shape is to provide constructors and accessors:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DeviceId(u32);

impl DeviceId {
    fn new(value: u32) -> Self {
        Self(value)
    }

    fn value(self) -> u32 {
        self.0
    }
}

Use it like this:

let id = DeviceId::new(7);
println!("{}", id.value());

If the ID has a constraint, put it in the constructor:

impl DeviceId {
    fn new(value: u32) -> Option<Self> {
        if value == 0 {
            None
        } else {
            Some(Self(value))
        }
    }
}

Now “device ID cannot be zero” is not just a comment. It is part of the construction path.

Struct typedef Maps to a Named struct

C often writes:

typedef struct {
    uint16_t port;
    uint8_t retry;
} Config;

Rust does not need type for this. Define a named struct directly:

struct Config {
    port: u16,
    retry: u8,
}

C also uses:

typedef struct Config Config;

That often supports an opaque handle pattern. In ordinary Rust libraries, use module visibility, private fields, and public methods:

pub struct Config {
    port: u16,
    retry: u8,
}

impl Config {
    pub fn new(port: u16, retry: u8) -> Self {
        Self { port, retry }
    }

    pub fn port(&self) -> u16 {
        self.port
    }
}

Fields without pub cannot be changed directly from outside the module. Callers use the methods you expose.

Be Careful Around FFI

A C header may contain:

typedef uint32_t device_id_t;

If Rust only needs to match the C ABI, an alias is often enough:

type DeviceIdRaw = u32;

If the Rust internal API should prevent mixing IDs, convert at the boundary into a newtype:

#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeviceId(u32);

#[repr(transparent)] says this single-field wrapper has the same ABI representation as its field. It is useful for newtypes near FFI boundaries. Ordinary domain newtypes do not always need it; use it when ABI representation is part of the contract.

A useful rule:

near the C ABI: keep the raw representation clear
inside Rust: use newtype to express domain boundaries

When to Use type and When to Use newtype

Use type when:

  • the type is long and the alias makes a signature readable
  • many functions share the same Result<T, E>
  • you need to name a function pointer, callback, or closure-like shape
  • you only want to mirror a C header name

Use newtype when:

  • two concepts share the same underlying type but must not mix
  • a value has construction constraints
  • a domain concept needs methods
  • an API signature should carry stronger meaning
  • you want to isolate raw integers, handles, indexes, or IDs inside Rust

What to Keep

C typedef does not have one Rust translation:

typedef uint32_t DeviceId      -> type DeviceId = u32
need DeviceId distinct from u32 -> struct DeviceId(u32)
complex return shorthand        -> type ParseResult<T> = Result<T, E>
struct typedef                  -> named Rust struct
opaque struct / encapsulation    -> private fields + pub methods
FFI single-field wrapper         -> #[repr(transparent)] newtype

type solves naming and readability. Newtype solves type boundaries and domain constraints. For C developers moving to Rust, the useful extra ability is not merely renaming a type; it is letting the compiler distinguish values that used to be separated only by naming convention.

Continue Reading