Beginner

Variables and Types in Hare

Learn about variables, data types, type annotations, and tagged unions in Hare with practical Docker-ready examples

Hare is a statically and strongly typed systems programming language, meaning every value has a known type at compile time and implicit conversions between types are restricted. This design catches type errors early and gives you precise control over memory layout — essential for the systems programming tasks Hare targets.

As an imperative systems language influenced by C and Go, Hare takes a straightforward approach to variables: you declare them with let or const, optionally annotate the type, and the compiler enforces correctness. What sets Hare apart is its tagged unions for error handling, non-null pointers by default, and a type system that eliminates many of the pitfalls found in C.

In this tutorial you will learn how to declare variables, work with Hare’s primitive types, use type annotations and inference, define compile-time constants, and get an introduction to tagged unions.

Variable Declarations

Hare provides two keywords for declaring variables within functions:

  • let — declares a mutable binding that can be reassigned
  • const — declares an immutable binding that cannot be reassigned after initialization

For compile-time constants at module scope, Hare uses the def keyword.

Create a file named variables.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use fmt;

// Compile-time constant at module scope
def MAX_SCORE: int = 100;

export fn main() void = {
	// Immutable binding - cannot be reassigned
	const language = "Hare";
	const year: int = 2022;

	// Mutable binding - can be reassigned
	let score: int = 85;
	let passed: bool = true;

	fmt::printfln("Language: {}", language)!;
	fmt::printfln("First appeared: {}", year)!;
	fmt::printfln("Score: {}/{}", score, MAX_SCORE)!;
	fmt::printfln("Passed: {}", passed)!;

	// Reassigning a mutable variable
	score = 92;
	passed = score >= 90;
	fmt::printfln("Updated score: {}", score)!;
	fmt::printfln("Updated passed: {}", passed)!;
};

The compiler infers the type of language as str from the string literal. For year, the type is explicitly annotated as int. Both forms are valid — explicit annotations are useful for documentation or when you need a specific integer size.

Primitive Types

Hare provides a clear set of primitive types organized by category:

Integer types — sized and unsized variants:

  • i8, i16, i32, i64 — signed integers of specific bit widths
  • u8, u16, u32, u64 — unsigned integers of specific bit widths
  • int, uint — platform-dependent signed and unsigned integers
  • size — unsigned integer sized for memory addresses and array indices

Floating-point types:

  • f32 — 32-bit IEEE 754 float
  • f64 — 64-bit IEEE 754 float

Other types:

  • booltrue or false
  • str — UTF-8 encoded string
  • rune — a single Unicode code point (written with single quotes)
  • void — the unit type, representing no value

Create a file named variables_types.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use fmt;

export fn main() void = {
	// Integer types
	const small: i8 = 127;
	const medium: i32 = 2_147_483_647;
	const large: i64 = 9_000_000_000;
	const unsigned: u32 = 4_000_000_000;
	const index: size = 42;

	fmt::printfln("i8 max: {}", small)!;
	fmt::printfln("i32 max: {}", medium)!;
	fmt::printfln("i64 value: {}", large)!;
	fmt::printfln("u32 value: {}", unsigned)!;
	fmt::printfln("size value: {}", index)!;

	// Floating-point types
	const pi: f64 = 3.14159265;
	const gravity: f32 = 9.81;

	fmt::printfln("pi: {}", pi)!;
	fmt::printfln("gravity: {}", gravity)!;

	// Boolean type
	const is_systems_lang = true;
	const uses_gc = false;

	fmt::printfln("Systems language: {}", is_systems_lang)!;
	fmt::printfln("Uses GC: {}", uses_gc)!;

	// String and rune types
	const greeting: str = "Hello, Hare!";
	const letter: rune = 'H';

	fmt::printfln("Greeting: {}", greeting)!;
	fmt::printfln("Letter: {}", letter)!;

	// Type casting with : operator
	const x: i32 = 10;
	const y: i64 = x: i64;
	const z: f64 = x: f64;

	fmt::printfln("i32: {}", x)!;
	fmt::printfln("as i64: {}", y)!;
	fmt::printfln("as f64: {}", z)!;
};

Note the use of underscores in numeric literals (2_147_483_647) for readability — the compiler ignores them. Type casting uses the : type postfix syntax, which makes conversions explicit and visible in code.

Tagged Unions and Nullable Types

One of Hare’s most distinctive features is its tagged union type system. A tagged union can hold a value of any one of its member types at a given time:

Create a file named variables_tagged.ha:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use fmt;

// A type alias for a tagged union
type result = (i64 | str | void);

export fn main() void = {
	// Tagged unions - a value can be one of several types
	let value: (int | str) = 42;
	fmt::printfln("Value: {}", value as int)!;

	value = "now a string";
	fmt::printfln("Value: {}", value as str)!;

	// Nullable types - pointers are non-null by default
	let x: int = 10;
	let ptr: *int = &x;
	fmt::printfln("Pointer value: {}", *ptr)!;

	// Nullable pointer requires explicit opt-in
	let maybe: nullable *int = null;
	fmt::println("Nullable pointer created")!;

	maybe = &x;
	match (maybe) {
	case null =>
		fmt::println("No value")!;
	case let p: *int =>
		fmt::printfln("Has value: {}", *p)!;
	};

	// Using tagged unions for results
	const res: result = 42i64;
	match (res) {
	case let n: i64 =>
		fmt::printfln("Got number: {}", n)!;
	case let s: str =>
		fmt::printfln("Got string: {}", s)!;
	case void =>
		fmt::println("Got nothing")!;
	};
};

Hare’s non-null pointers are a key safety feature. In C, any pointer could be NULL, leading to crashes. In Hare, you must explicitly opt into nullability with the nullable keyword, and then use match to safely handle both cases.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pull the Alpine edge image
docker pull alpine:edge

# Run the basic variables example
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run variables.ha"

# Run the types example
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run variables_types.ha"

# Run the tagged unions example
docker run --rm -v $(pwd):/code alpine:edge sh -c "apk add --no-cache hare > /dev/null 2>&1 && cd /code && hare run variables_tagged.ha"

Expected Output

For variables.ha:

Language: Hare
First appeared: 2022
Score: 85/100
Passed: true
Updated score: 92
Updated passed: true

For variables_types.ha:

i8 max: 127
i32 max: 2147483647
i64 value: 9000000000
u32 value: 4000000000
size value: 42
pi: 3.14159265
gravity: 9.81
Systems language: true
Uses GC: false
Greeting: Hello, Hare!
Letter: H
i32: 10
as i64: 10
as f64: 10

For variables_tagged.ha:

Value: 42
Value: now a string
Pointer value: 10
Nullable pointer created
Has value: 10
Got number: 42

Key Concepts

  • let vs constlet creates mutable bindings that can be reassigned; const creates immutable bindings that cannot
  • def — defines compile-time constants at module scope, not runtime bindings
  • Static type inference — the compiler can infer types from initializers, but explicit annotations are always available with : type syntax
  • Sized integer types — Hare provides exact-width types (i32, u64) alongside platform-dependent types (int, size), giving precise control over memory layout
  • Tagged unions — a value can be one of several types, checked at runtime with match; this is the foundation of Hare’s error handling
  • Non-null pointers — pointers cannot be null unless explicitly declared nullable, eliminating an entire class of bugs
  • Explicit type casting — conversions between numeric types use the : type postfix operator, making every conversion visible in code
  • Underscored literals — numeric literals support underscores (1_000_000) for readability without affecting the value

Running Today

All examples can be run using Docker:

docker pull alpine:edge
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining