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.