Rust Data Types

Static Typing

Rust is a statically typed language: the type of every value, variable, and expression is known at compile time, and the compiler checks that every operation is valid for the types involved before the program is built. A program that uses a value as the wrong type does not compile, so it never runs.

Rust is also strongly typed: it does not silently convert a value from one type to another. Every conversion between types is written out in the source code.

What a Type Is

A type is a description attached to a value. It answers three questions:

Question Example for i32 Example for f64
How many bytes does a value occupy, and how are they laid out? 4 bytes, two's complement 8 bytes, IEEE 754 binary64
What do the bits mean? A whole number from -2147483648 to 2147483647 A floating-point number
Which operations are allowed? +, -, %, &, <<, .pow(), ... +, -, %, .sqrt(), .sin(), ... but no & or <<

The bits in memory carry no meaning on their own. The same 32 bits are the integer 1065353216 when read as a u32 and the number 1.0 when read as an f32:

fn main() {
    let bits: u32 = 0x3f80_0000;
    println!("as u32: {bits}");
    println!("as f32: {}", f32::from_bits(bits));
    println!("as 4 bytes: {:?}", bits.to_be_bytes());
}
as u32: 1065353216
as f32: 1
as 4 bytes: [63, 128, 0, 0]

The type is what tells the compiler which interpretation to use. Because the compiler knows every type, it knows the size of every value and can choose the correct machine instructions: integer addition for i32, floating-point addition for f64.

Static Versus Dynamic Typing

In a dynamically typed language, types belong to values and are checked while the program runs. In a statically typed language, types belong to expressions in the source and are checked by the compiler.

Static typing (Rust, C, Java, Go) Dynamic typing (Python, JavaScript, Ruby)
When types are checked At compile time At run time
A type mistake is found Before the program starts When the faulty line executes, possibly in production
Type information at run time Not needed; the compiled code already uses the right operations Every value carries a type tag that is inspected on each operation
A variable can hold values of different types No, one type for its lifetime Yes
Cost of the checks at run time None Yes

Static typing moves a whole category of mistakes, such as passing a string where a number is expected, from run time to compile time.

Type Checking at Compile Time

When the type of a value does not match the type that is required, the compiler reports error E0308, mismatched types:

// ❌ does not compile
fn main() {
    let count: i32 = "five";
    println!("{count}");
}
error[E0308]: mismatched types
 --> main.rs:3:22
  |
3 |     let count: i32 = "five";
  |                ---   ^^^^^^ expected `i32`, found `&str`
  |                |
  |                expected due to this

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

The check is applied everywhere a type is required: assignments, function arguments, return values, if branches, operator operands, and struct fields.

Variables Keep One Type

A variable has the same type for its entire lifetime. mut allows the value to change, not the type:

// ❌ does not compile
fn main() {
    let mut value = 5;
    value = "five";
    println!("{value}");
}
error[E0308]: mismatched types
 --> main.rs:4:13
  |
3 |     let mut value = 5;
  |                     - expected due to this value
4 |     value = "five";
  |             ^^^^^^ expected integer, found `&str`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

To use the same name for a value of a different type, shadow the variable with a new let. Each let creates a new variable that happens to reuse the name:

fn main() {
    let input = "42";
    let input = input.len();
    let input = input as f64 * 1.5;
    println!("{input}");
}
3

Here input is a &str, then a usize, then an f64. These are three separate variables, and each one has a single fixed type.

Function Boundaries

Parameter types and the return type are part of a function's signature, and both sides of every call are checked against it:

// ❌ does not compile
fn double(n: i32) -> i32 {
    n * 2
}

fn main() {
    let x: i64 = 4;
    println!("{}", double(x));
}
error[E0308]: mismatched types
 --> main.rs:8:27
  |
8 |     println!("{}", double(x));
  |                    ------ ^ expected `i32`, found `i64`
  |                    |
  |                    arguments to this function are incorrect
  |
note: function defined here
 --> main.rs:2:4
  |
2 | fn double(n: i32) -> i32 {
  |    ^^^^^^ ------
help: you can convert an `i64` to an `i32` and panic if the converted value doesn't fit
  |
8 |     println!("{}", double(x.try_into().unwrap()));
  |                            ++++++++++++++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

The return value is checked in the same way. A function declared to return String cannot return a &str:

// ❌ does not compile
fn describe(n: i32) -> String {
    if n > 0 {
        "positive"
    } else {
        "not positive".to_string()
    }
}

fn main() {
    println!("{}", describe(3));
}
error[E0308]: mismatched types
 --> main.rs:4:9
  |
2 | fn describe(n: i32) -> String {
  |                        ------ expected `String` because of return type
3 |     if n > 0 {
4 |         "positive"
  |         ^^^^^^^^^^ expected `String`, found `&str`
  |
help: try using a conversion method
  |
4 |         "positive".to_string()
  |                   ++++++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

Every Expression Has One Type

Rust is expression-based, and each expression has exactly one type. This is why both branches of an if / else must produce the same type:

// ❌ does not compile
fn main() {
    let flag = true;
    let n = if flag { 1 } else { "one" };
    println!("{n}");
}
error[E0308]: `if` and `else` have incompatible types
 --> main.rs:4:34
  |
4 |     let n = if flag { 1 } else { "one" };
  |                       -          ^^^^^ expected integer, found `&str`
  |                       |
  |                       expected because of this

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

The same rule applies to the arms of a match.

Strong Typing: No Implicit Conversions

Many languages convert between numeric types automatically. In C, int + long widens the int and int + double converts it to a floating-point value. Rust performs none of these conversions. Adding an i32 to an i64 is an error:

// ❌ does not compile
fn main() {
    let small: i32 = 10;
    let big: i64 = 20;
    let total = small + big;
    println!("{total}");
}
error[E0308]: mismatched types
 --> main.rs:5:25
  |
5 |     let total = small + big;
  |                         ^^^ expected `i32`, found `i64`

The compiler also reports a second error, E0277, cannot add i64toi32``, because + is only defined between matching numeric types.

An integer literal is not a float either:

// ❌ does not compile
fn main() {
    let y: f64 = 4;
}
error[E0308]: mismatched types
 --> main.rs:3:18
  |
3 |     let y: f64 = 4;
  |            ---   ^ expected `f64`, found integer
  |            |
  |            expected due to this
  |
help: use a float literal
  |
3 |     let y: f64 = 4.0;
  |                   ++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

Two reasons for this strictness:

  • A silent conversion can lose information (a large i64 squeezed into an i32, or a large integer rounded when turned into a float). Rust makes every such step visible in the code.
  • Mixed-type arithmetic hides which operation really runs. Requiring the same type on both sides keeps the meaning of a + b unambiguous.

The explicit ways to convert are as, From / Into, and TryFrom / TryInto. They are covered in the sections on casting.

No Null Value

Many dynamically and statically typed languages let any reference be null, so a value of type String may secretly be missing. Rust has no null value. A value that may be absent has a different type, Option<T>, and the compiler requires the absent case to be handled before the inner value can be used:

// ❌ does not compile
fn main() {
    let found: Option<i32> = Some(3);
    let doubled = found * 2;
    println!("{doubled}");
}
error[E0369]: cannot multiply `Option<i32>` by `{integer}`
 --> main.rs:4:25
  |
4 |     let doubled = found * 2;
  |                   ----- ^ - {integer}
  |                   |
  |                   Option<i32>
  |
note: `Option<i32>` does not implement `Mul<{integer}>`
 --> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/option.rs:598:0
  |
  = note: `Option<i32>` is defined in another crate

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0369`.

Option<i32> and i32 are different types. Static typing is what turns "this value may be missing" into something the compiler can enforce.

Where Types Must Be Written

Rust can often work out the type of a local variable on its own (see type inference), but some places always need an explicit type. These positions form the boundaries where the compiler stops inferring and starts checking.

Position Example
Function parameters fn area(width: u32, height: u32)
Function return type fn area(width: u32, height: u32) -> u32
Struct and enum fields struct Point { x: f64, y: f64 }
const and static items const MAX_USERS: u32 = 100;
Closure parameters and local variables Optional, usually inferred
const MAX_USERS: u32 = 100;

struct Point {
    x: f64,
    y: f64,
}

fn distance_from_origin(p: &Point) -> f64 {
    (p.x * p.x + p.y * p.y).sqrt()
}

fn main() {
    let point = Point { x: 3.0, y: 4.0 };
    let limit: u32 = MAX_USERS;
    println!("distance = {}", distance_from_origin(&point));
    println!("limit = {limit}");
}
distance = 5
limit = 100

Requiring types at function signatures means that a function can be understood and checked on its own, without reading its callers or its body from elsewhere.

Type Annotations and Suffixes

A type annotation is written after a colon: let x: u8 = 7;. Numeric literals can also carry the type as a suffix:

fn main() {
    let a: u8 = 7;
    let b = 7u8;
    let c = 7_u8;
    let d = 2.5f32;
    println!("{a} {b} {c} {d}");
}
7 7 7 2.5

All three forms of the first value produce the same u8.

Querying a Type

The type of a value is a compile-time fact, but the standard library exposes the name of the type and its size so you can inspect it:

use std::any::type_name_of_val;
use std::mem::size_of;

fn main() {
    let a = 1u8;
    let b = 1i64;
    let c = 1.0f32;
    let d = 'x';
    let e = true;
    let f = ();
    println!("{} {}", type_name_of_val(&a), size_of::<u8>());
    println!("{} {}", type_name_of_val(&b), size_of::<i64>());
    println!("{} {}", type_name_of_val(&c), size_of::<f32>());
    println!("{} {}", type_name_of_val(&d), size_of::<char>());
    println!("{} {}", type_name_of_val(&e), size_of::<bool>());
    println!("{} {}", type_name_of_val(&f), size_of::<()>());
}
u8 1
i64 8
f32 4
char 4
bool 1
() 0

type_name_of_val returns a &'static str that the compiler fills in at compile time. It is intended for diagnostics and debugging, and the exact text it returns for complex types is not guaranteed to stay the same between compiler versions.

Types Do Not Exist at Run Time

After type checking succeeds, the compiler generates machine code that uses the right instructions for the types involved. The compiled program does not carry a type tag next to each value and does not check types as it runs. This is why static typing in Rust has no run-time cost.

Source code                  Compile time                     Run time
+----------------+          +----------------------+         +-----------------+
| let a: i32 = 1 |          | check every type     |         | raw bytes in    |
| let b: i32 = 2 |  ----->  | choose instructions  |  --->   | registers and   |
| let c = a + b  |          | compute sizes/layout |         | memory, no tags |
+----------------+          +----------------------+         +-----------------+

Generic code follows the same rule. For fn largest<T: PartialOrd>(a: T, b: T) -> T, the compiler creates a separate, fully typed copy of the function for each concrete T that the program uses. This process is called monomorphization, and it means that generics also cost nothing at run time.

The Families of Types

Rust's types fall into a small number of families. Each is covered in its own section.

Family Types Examples
Scalar A single value i32, u8, f64, bool, char
Compound Several values grouped together tuples (i32, f64), arrays [i32; 3]
Slices and strings A view into a sequence &[i32], &str
User-defined Types the programmer declares struct, enum, union
References and pointers Types that refer to other values &T, &mut T, *const T, Box<T>
Function and closure types Callable values fn(i32) -> i32, closures
Special types Types with a unique role () (unit), ! (never)