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
i64squeezed into ani32, 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 + bunambiguous.
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) |