Lesson 3 of 22·15 min

Primitive Types

Every value in Rust has a type, and the compiler knows all of them before the program runs. The simplest types are the primitives: integers, floating-point numbers, booleans and characters. They are built into the language, and everything else you will write is made out of them.

This lesson goes through each primitive type, the arithmetic you can do with numbers, what happens when a number gets too big for its type, and how to convert one numeric type into another with as.

Integers

An integer type is named by whether it can be negative and how many bits it uses. Types starting with i are signed (they can hold negative numbers), types starting with u are unsigned (zero and up):

BitsSignedUnsignedUnsigned range
8i8u80 to 255
16i16u160 to 65,535
32i32u320 to about 4.3 billion
64i64u640 to about 1.8 x 10^19
128i128u1280 to about 3.4 x 10^38
archisizeusizesame as 32 or 64 bits

A signed type covers the same number of values, shifted so that zero sits in the middle: i8 goes from -128 to 127. Every integer type has MIN and MAX constants if you need the exact limits:

fn main() {
    println!("u8:  {} to {}", u8::MIN, u8::MAX);
    println!("i8:  {} to {}", i8::MIN, i8::MAX);
    println!("i32: {} to {}", i32::MIN, i32::MAX);
}
u8:  0 to 255
i8:  -128 to 127
i32: -2147483648 to 2147483647

usize and isize are as wide as a memory address on the machine you compile for, which is 64 bits on almost every computer today. usize is the type Rust uses for sizes and positions: the length of a collection and the index you use to look something up in it are both usize.

When you write an integer without saying which type it is, and nothing else forces a type, Rust picks i32. It is a good default: fast everywhere and big enough for most counters and small quantities.

Integer literals

Rust gives you a few ways to write integers:

fn main() {
    let population = 8_100_000_000_u64;
    let small = 7u8;
    let mask = 0xff;
    let flags = 0b1010_0001;
    let permissions = 0o755;
    let letter = b'R';
    println!("{population} {small} {mask} {flags} {permissions} {letter}");
}
8100000000 7 255 161 493 82
  • Underscores are ignored, so 8_100_000_000 is easier to read than 8100000000.
  • A suffix picks the type: 7u8 is a u8, 8_100_000_000_u64 a u64.
  • 0x, 0b and 0o start hexadecimal, binary and octal numbers.
  • b'R' is a byte literal: the ASCII code of a character, as a u8.

Floating-point numbers

Rust has two floating-point types, f32 and f64, for 32 and 64 bits. The default is f64: it is about as fast as f32 on modern CPUs and much more precise. A float literal needs a decimal point or an exponent:

fn main() {
    let price = 19.99;
    let ratio: f32 = 0.75;
    let distance = 1.5e3;
    let whole = 4.0;
    println!("{price} {ratio} {distance} {whole}");
}
19.99 0.75 1500 4

4.0 is a float and 4 is an integer; they are different types. Floats follow the IEEE 754 standard used by nearly every language, with its familiar rounding surprises:

fn main() {
    let sum = 0.1 + 0.2;
    println!("{sum}");
    println!("{}", sum == 0.3);
}
0.30000000000000004
false

Neither 0.1 nor 0.2 can be stored exactly in binary, so the result is a tiny bit off. Compare floats against a small tolerance rather than with ==, and don't use them for money.

Booleans

bool has two values, true and false. You usually get one from a comparison, and combine them with && (and), || (or) and ! (not):

fn main() {
    let age = 17;
    let has_permission = true;
 
    let is_adult = age >= 18;
    let can_enter = is_adult || has_permission;
    let is_minor = !is_adult;
    println!("adult: {is_adult}, can enter: {can_enter}, minor: {is_minor}");
}
adult: false, can enter: true, minor: true

The comparison operators are ==, !=, <, <=, > and >=. && and || short-circuit: the right side is only evaluated if it can still change the answer. Rust never treats a number as a boolean, so if count { ... } is an error; write if count != 0 instead.

Characters

char holds a single Unicode character and is written with single quotes. Double quotes make a string, which is a different type:

fn main() {
    let letter = 'z';
    let digit = '7';
    let crab = '🦀';
    let accented = 'é';
    println!("{letter} {digit} {crab} {accented}");
    println!("{}", digit.is_ascii_digit());
    println!("{}", letter.to_ascii_uppercase());
    println!("{}", std::mem::size_of::<char>());
}
z 7 🦀 é
true
Z
4

A char is always 4 bytes, enough for any Unicode scalar value: letters from any alphabet, digits, symbols and emoji. That is not the same as a byte (u8), and it is not always what a person would call one character: some visible characters, like many flags, are built from several chars.

Arithmetic

Numbers support the usual operators: +, -, *, / and % (remainder):

fn main() {
    let apples = 17;
    let people = 5;
    println!("each gets {}", apples / people);
    println!("left over {}", apples % people);
    println!("as floats {}", 17.0 / 5.0);
    println!("negative {} {}", -17 / 5, -17 % 5);
}
each gets 3
left over 2
as floats 3.4
negative -3 -2

Integer division throws away the fractional part: 17 / 5 is 3, not 3.4. It rounds towards zero, so -17 / 5 is -3, and the remainder takes the sign of the left-hand side. If you want a fractional answer, divide floats.

Dividing an integer by zero panics (the program stops with an error). Dividing a float by zero gives inf, or NaN ("not a number") for 0.0 / 0.0.

Both sides of an operator must be the same type. Rust will not quietly turn an i32 into an f64, or a u8 into a u32, for you. You convert explicitly, which is what as is for (see below).

Numbers also come with methods for things that aren't operators:

fn main() {
    let x: i32 = -9;
    let y: f64 = 2.0;
    println!("{} {}", x.abs(), x.pow(2));
    println!("{} {:.3}", y.sqrt(), y.powf(0.5));
    println!("{} {}", 7.5_f64.floor(), 7.5_f64.round());
    println!("{}", 3.max(8));
}
9 81
1.4142135623730951 1.414
7 8
8

Overflow

Each integer type has a fixed range, so what happens when a result doesn't fit? In a debug build (the default for cargo run), Rust checks every arithmetic operation and panics on overflow:

fn main() {
    let mut volume: u8 = 250;
    for _ in 0..10 {
        volume += 1;
    }
    println!("{volume}");
}

(for _ in 0..10 repeats the body ten times; loops get their own lesson.)

In a release build (cargo build --release) the checks are off for speed and the value wraps around instead: 255 + 1 becomes 0. Code that relies on either behaviour is fragile, so when overflow is possible, say what you want with one of the explicit methods:

fn main() {
    let volume: u8 = 250;
    println!("{:?}", volume.checked_add(3));
    println!("{:?}", volume.checked_add(10));
    println!("{}", volume.wrapping_add(10));
    println!("{}", volume.saturating_add(10));
}
Some(253)
None
4
255
  • checked_add returns Some(result), or None if it would overflow. The lesson on Option and Result explains how to use that value.
  • wrapping_add wraps around on purpose: 250 + 10 is 260, which is 4 past 256.
  • saturating_add stops at the type's limit: 255.

The same families exist for the other operators: checked_sub, wrapping_mul, saturating_sub and so on.

Converting with as

Because Rust never converts numbers for you, you do it with as:

fn main() {
    let items: u8 = 12;
    let price: f64 = 2.5;
    let total = items as f64 * price;
    println!("{total}");
 
    let rating = 4.8_f64;
    println!("{}", rating as u32);
 
    let code = 'A' as u32;
    let next = (code + 1) as u8 as char;
    println!("{code} {next}");
}
30
4
65 B

Converting to a wider type of the same kind, like u8 to u32 or i32 to i64, always keeps the value. Other conversions can change it, and as never complains:

fn main() {
    let big: i32 = 300;
    let negative: i16 = -1;
    let huge: f64 = 1e12;
    println!("{}", big as u8);
    println!("{}", negative as u16);
    println!("{}", huge as i32);
    println!("{}", -2.7_f64 as u32);
}
44
65535
2147483647
0
  • Integer to smaller integer keeps only the low bits: 300 is 256 + 44, so it becomes 44.
  • Signed to unsigned of the same size reinterprets the bits: -1 as u16 is 65535, the largest u16.
  • Float to integer drops the fraction and clamps to the target's range: 1e12 becomes i32::MAX, and a negative float becomes 0 as an unsigned integer.

So as is right when you know the value fits, or when truncating is what you want. When a value might not fit and you need to know, use try_from, which returns a Result you can check (covered in the error handling lessons):

fn main() {
    let big: i32 = 300;
    println!("{:?}", u8::try_from(big));
    println!("{:?}", u8::try_from(200));
}
Err(TryFromIntError(PosOverflow))
Ok(200)

Common mistakes

Mixing numeric types

Adding an integer to a float, or two different integer types, doesn't compile:

fn main() {
    let count: u32 = 3;
    let average: f64 = 2.5;
    let total = count * average;
    println!("{total}");
}

Convert one side so both match: count as f64 * average.

A literal that doesn't fit its type

The compiler rejects a literal outside its type's range:

fn main() {
    let level: u8 = 256;
    println!("{level}");
}

Pick a type big enough for the values you need, such as u16.

Expecting a fraction from integer division

7 / 2 is 3, because both sides are integers. If you want 3.5, the values have to be floats before the division: 7.0 / 2.0, or a as f64 / b as f64 for variables. Converting afterwards, (a / b) as f64, is too late: the fraction is already gone.

Double quotes for a char

fn main() {
    let initial: char = "R";
    println!("{initial}");
}

"R" is a string slice (&str), even with one character in it. A char uses single quotes: 'R'.

Summary

  • Integers are i8 to i128 (signed) and u8 to u128 (unsigned), plus isize/usize for sizes and indexes. The default is i32.
  • Floats are f32 and f64, default f64. They round, so don't compare them with ==.
  • bool is true or false and is never a number. char is a 4-byte Unicode character in single quotes.
  • + - * / % need both sides to be the same type. Integer division drops the fraction.
  • Overflow panics in debug builds and wraps in release; use checked_*, wrapping_* or saturating_* to choose.
  • as converts between numeric types (and char) without checks; try_from converts only if the value fits.

Practise what you read

Challenges that use the ideas from this lesson.