Skip to content

Data Types

Now that you know how to create variables and constants, it’s time to learn what kinds of values they can store. These are called data types.

Ferret comes with a set of built‑in types that let you work with numbers, text, true/false values, and more.

Primitive types are the simplest kinds of data. Internally they are just numbers. Unlike other languages, Ferret has a rich set of primitive types to give you more control over how data is stored and manipulated. We support maximum 256-bit integers and floating-point numbers with up to 71 decimal digits of precision! And it’s built right into the language without needing any special libraries.

These types store whole numbers.

TypeSizeRangeDescription
i88‑bit-2⁷ to 2⁷‑1Small integer
i1616‑bit-2¹⁵ to 2¹⁵‑1Medium integer
i3232‑bit-2³¹ to 2³¹‑1Standard integer
i6464‑bit-2⁶³ to 2⁶³‑1Bigger integer
i128128‑bit-2¹²⁷ to 2¹²⁷‑1Very big integer
i256256‑bit-2²⁵⁵ to 2²⁵⁵‑1Extremely big integer
u88‑bit0 to 2⁸‑1Non‑negative small integer
u1616‑bit0 to 2¹⁶‑1Non‑negative medium integer
u3232‑bit0 to 2³²‑1Non‑negative integer
u6464‑bit0 to 2⁶⁴‑1Bigger non‑negative integer
u128128‑bit0 to 2¹²⁸‑1Very big non‑negative integer
u256256‑bit0 to 2²⁵⁶‑1Extremely big non‑negative integer

Now if you are confused about the i and u prefixes, i stands for signed integers (can be negative) and u stands for unsigned integers (non-negative only). And the numbers 32 and 64 stand for the number of bits used to store the value. Other languages may use different names for these types, but the concepts are the same. So when you see i32, think of it as a 32-bit signed integer.

Now remember the := operator you learned about in the Variables & Constants section? It is used for declaring variables and constants with type inference. Type inference means Ferret can automatically figure out the type based on the value you provide. But if you want to explicitly specify the type, you can do so using a colon : followed by the type name.

let count: i32 = 42;
let small: i8 = -128;
let big_number: i64 = 9223372036854775807;
let positive: u32 = 4294967295;
let very_big: u128 = 340282366920938463463374607431768211455;
let huge: u256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935;

These types store numbers with decimal points. Think of them as numbers that can have fractional parts.

TypeSizePrecisionDescription
f3232‑bit~7 digitsSingle precision float
f6464‑bit~15 digitsDouble precision (default)
f128128‑bit~34 digitsQuadruple precision float
f256256‑bit~71 digitsOctuple precision float

The f stands for floating-point, and the numbers 32 and 64 represent the bits used to store the value. The bigger the number, the more precise your decimal calculations will be.

When you write a number with a decimal point without specifying a type, Ferret automatically uses the default f32 because it gives you better precision. But if the value requires more precision, it will promote it to f64 and so on.

let pi: f32 = 3.14159;
let e: f64 = 2.718281828459045;
let price := 19.99; // Inferred as f64
let large_value: f128 = 1.2345678901234567890123456789012345;
let precise_value: f256 = 1.2345678901234567890123456789012345678901234567890123456789012345678901234567890;

Strings store text - anything from single letters to entire paragraphs. In Ferret, strings are represented by the str type.

You create strings by wrapping text in double quotes ".

let name: str = "Ferret";
let greeting: str = "Hello, World!";
let emoji: str = "🦦"; // Strings support Unicode, including emojis!
// Strings can span multiple lines
let multiline: str = "Hello
World";

Strings are one of the most common types you’ll work with. They’re perfect for storing names, messages, file paths, and any other text data.

Strings are indexable. Indexing returns a byte (not a str), is byte-based (not Unicode code points), and uses runtime bounds checks. Negative indices count from the end (-1 is last byte):

let s: str = "Hello";
let first: byte = s[0];
let last: byte = s[-1]; // last byte
let n: i32 = len(s);

A character represents a single letter, symbol, or emoji. Unlike strings that can hold multiple characters, the byte type holds exactly one character. It is called byte because it typically uses one byte (8 bits) of memory to store the value. Internally byte and u8 are the same.

Characters are created using single quotes ' instead of double quotes.

let letter: byte = 'A';
let newline: byte = '\n'; // Special characters use backslash

Think of a byte as a single building block, while a str (string) is like a sequence of these blocks.

When printing, byte displays as a character while u8 prints as a number.

Booleans represent yes/no, on/off, or true/false values. There are only two possible values: true and false.

The type name is bool, and booleans are essential for making decisions in your code.

let is_active: bool = true;
let is_complete: bool = false;
let has_permission := true; // Inferred as bool

You’ll use booleans constantly when writing conditions, like “if the user is logged in” or “while the game is running.”

Compound types are built by combining other types together. They let you group related data.

Arrays are collections that store multiple values of the same type in a specific order. Think of them as numbered containers where each slot holds one value.

There are two kinds of arrays in Ferret:

Dynamic arrays automatically grow when you add more elements. Use the append() builtin function to append an item to the end of an array.

let numbers: []i32 = [1, 2, 3, 4, 5];
let names: []str = ["Alice", "Bob", "Charlie"];
let scores := [95, 87, 92]; // Inferred as []i32
// Dynamic arrays
let arr := [1, 2, 4]; // size 3
append(&mut arr, 43); // Append using mutable reference

Notice the [] before the type - this means “an array of” that type. Dynamic arrays have no bounds checking - they grow to accommodate any index you use.

Learn more: See the Built-in Functions documentation for complete details on array operations like get(), set(), insert(), and more.

Fixed-size arrays have a set number of elements that cannot change:

let days: [7]str = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let coordinates: [3]f64 = [1.0, 2.5, 3.7];

The number in brackets [7] tells you exactly how many items the array holds. Fixed-size arrays have compile-time bounds checking for constant indices:

let arr: [5]i32 = [1, 2, 3, 4, 5];
let x := arr[2]; // OK - index 2 is valid
let y := arr[10]; // Compile error: constant index 10 is out of bounds!
// Runtime indices panic if out of bounds
let i := 10;
let z := arr[i]; // Runtime panic: index out of bounds!

Both array types support negative indices to access elements from the end:

let numbers: [5]i32 = [10, 20, 30, 40, 50];
let last := numbers[-1]; // 50 (last element)
let second_last := numbers[-2]; // 40 (second to last)

Negative indices count backwards: -1 is the last element, -2 is second to last, and so on. For fixed-size arrays, out-of-bounds negative constant indices are caught at compile-time:

let arr: [5]i32 = [1, 2, 3, 4, 5];
let valid := arr[-5]; // OK - first element
let invalid := arr[-6]; // Compile error: constant index -6 is out of bounds!
// Runtime negative indices panic if out of bounds
let i := -10;
let unsafe := arr[i]; // Runtime panic: index out of bounds!

Important: For safe array access without panics, use the built-in functions:

  • get(&arr, index) - Returns T? (optional), none if out of bounds
  • get_or(&arr, index, fallback) - Returns T with fallback if out of bounds
  • has(&arr, index) - Returns bool to check if index is valid
Array TypeConstant IndicesRuntime IndicesNegative Indexing
Fixed-size [N]T✅ Compile error if out of bounds❌ Runtime panic if out of bounds✅ Same behavior
Dynamic []T❌ Runtime panic if out of bounds❌ Runtime panic if out of bounds✅ Same behavior

Both fixed-size and dynamic arrays panic on out-of-bounds access at runtime. Use the built-in functions (get(), get_or(), has()) for safe access without panics.

Sometimes you need to represent “I might have a value, or I might not.” That’s what optional types do.

You make any type optional by adding a question mark ? after it. An optional type T? can hold either a value of type T or none. The none keyword is a constant (like true and false) that represents the absence of a value.

let maybe_number: i32? = 42; // Has a value (42)
let no_value: str? = none; // No value (none is a constant like true/false)
let age: i32? = none; // Starts with no value

Optional types help prevent bugs. Instead of crashing when something is missing, Ferret forces you to check if a value exists before using it.

let username: str? = get_username();
if username != none {
// Safe to use username here
io::Println("Hello, " + username);
} else {
io::Println("No username provided");
}

This is much safer than many other languages where missing values can cause crashes!

Maps are collections that store key-value pairs. Think of them like a dictionary where you look up values using keys instead of positions.

Unlike arrays which use numbers (0, 1, 2…) to access elements, maps let you use any type as a key - strings, numbers, or even custom types!

let scores: map[str]i32 = {
"alice" => 95,
"bob" => 87,
"charlie" => 92
} as map[str]i32;
let prices: map[str]f64 = {
"apple" => 1.99,
"banana" => 0.99
} as map[str]f64;

The syntax map[KeyType]ValueType tells Ferret what types the keys and values should be.

Ferret provides two ways to access map values:

Direct indexing map[key] returns the value type T directly, but panics if the key doesn’t exist:

let ages := {"alice" => 25, "bob" => 30} as map[str]i32;
// Returns i32 directly (not i32?)
let alice_age: i32 = ages["alice"]; // ✅ 25
let missing: i32 = ages["unknown"]; // ❌ Panic: key not found!

Safe access with the get() builtin returns an optional type:

let ages := {"alice" => 25, "bob" => 30} as map[str]i32;
// Returns i32? (optional i32) - key might not exist!
let alice_age: i32? = get(&mut ages, "alice"); // Returns i32? with value 25
let missing: i32? = get(&mut ages, "unknown"); // Returns i32? with value none

This is a safety feature! It forces you to think about what happens when a key doesn’t exist, preventing crashes.

The coalescing operator ?? is perfect for providing default values with get():

let scores := {"alice" => 95} as map[str]i32;
let alice_score := get(&mut scores, "alice") ?? 0; // 95
let bob_score := get(&mut scores, "bob") ?? 0; // 0 (key doesn't exist)

Or use the get_or() builtin for a more concise syntax:

let alice_score := get_or(&mut scores, "alice", 0); // 95
let bob_score := get_or(&mut scores, "bob", 0); // 0 (fallback)

This pattern is so common you’ll use it all the time when working with maps!

Learn more: Maps are covered in detail in the Type System section, and see Built-in Functions for all container operations.

Reference types let you pass data by reference rather than by copy. Add & before a type to make it a reference:

type LargeData struct {
.buffer: [1000]i32,
.metadata: str,
};
// Passes by copy (copies entire struct)
fn process_copy(data: LargeData) { }
// Passes by reference (only copies pointer)
fn process_ref(data: &LargeData) { }

References are useful for:

  • Avoiding expensive copies of large data
  • Sharing data between functions

Learn more: References are covered in detail in the Type System section.

Ferret lets you define your own types beyond the built-in ones. The most common custom types are structs, enums, and interfaces.

You use the type keyword to define new structured types:

// Define a struct type
type Point struct {
.x: f64,
.y: f64
};
// Define an enum type
type Color enum {
Red,
Green,
Blue
};
let point: Point = { .x: 10.0, .y: 20.0 } as Point;
let color: Color = Color::Red;

These custom types make your code more organized and type-safe. We’ll dive deeper into structs, enums, and interfaces in the Type System section.

Ferret enforces strict type matching for all numeric operations, similar to Rust. This means you cannot mix different numeric types in arithmetic operations without explicit casting.

  1. No Surprises: You always know exactly what type you’re working with
  2. Performance: No hidden runtime conversions or checks
  3. Safety: Prevents accidental truncation or precision loss
  4. Predictability: The type system is simple and deterministic
  5. Explicit Intent: Casts document your intentions in code

When performing arithmetic operations, both operands must be the same type, and the result will be that same type:

let a: i32 = 100;
let b: i256 = 340282366920938463463374607431768211456;
// ❌ ERROR: mismatched types in arithmetic: i32 and i256
// let result := a + b;
// ✅ CORRECT: Explicit cast required
let result := (a as i256) + b; // result is i256

This applies to all arithmetic operators: +, -, *, /, %, and **.

You cannot mix integers and floats without explicit casting:

let integer: i32 = 42;
let floating: f64 = 3.14159;
// ❌ ERROR: mismatched types
// let sum := integer + floating;
// ✅ CORRECT: Cast integer to float
let sum := (integer as f64) + floating; // result is f64

Even integers of different sizes require explicit casting:

let small: i32 = 100;
let large: i64 = 9223372036854775807;
// ❌ ERROR: mismatched types
// let result := small + large;
// ✅ CORRECT: Cast to matching type
let result := (small as i64) + large; // result is i64
// Or cast down (with potential overflow):
let result2 := small + (large as i32); // result is i32

The same rule applies to floating-point types:

let f32val: f32 = 3.14;
let f64val: f64 = 2.718281828;
// ❌ ERROR: mismatched types
// let result := f32val + f64val;
// ✅ CORRECT: Cast to matching type
let result := (f32val as f64) + f64val; // result is f64

The power operator (**) follows the same strict typing rules:

let base: i256 = 2;
let exp: i32 = 10;
// ❌ ERROR: mismatched types
// let result := base ** exp;
// ✅ CORRECT: Cast to matching type
let result := base ** (exp as i256); // result is i256

Ferret requires explicit type conversions for all numeric operations. Use the as keyword to cast between types.

Use the as keyword to convert between number types:

let small: i32 = 42;
let big: i64 = small as i64; // Convert to bigger integer
let whole: i32 = 100;
let decimal: f64 = whole as f64; // Convert to floating-point
let pi: f64 = 3.14159;
let rounded: i32 = pi as i32; // Becomes 3 (decimal part removed)

Widening (Safe - No Data Loss):

let small: i32 = 100;
let large := small as i64; // i32 → i64 (safe)
let huge := small as i256; // i32 → i256 (safe)
let f32val: f32 = 3.14;
let f64val := f32val as f64; // f32 → f64 (safe)

Narrowing (Unsafe - Potential Data Loss):

let large: i64 = 9223372036854775807;
let small := large as i32; // i64 → i32 (truncates!)
let precise: f64 = 3.14159265358979;
let lessPrec := precise as f32; // f64 → f32 (loses precision)

There is no built-in way to convert between strings and other types yet. This is done via standard library functions which will be covered later.

You’ve learned about Ferret’s type system! Here’s what we covered:

  • Primitive types: Integers (i32, i64, u32, u64), floats (f32, f64), strings (str), booleans (bool), and characters (byte)
  • Compound types: Arrays and maps that hold multiple values
  • Optional types: Types that can be a value or none
  • Reference types: Pass data by reference with &T
  • Type inference: Letting Ferret figure out types automatically
  • Type conversion: Explicitly changing between types

Now that you know about types, you’re ready to learn what you can do with them: