We have covered some of Odin’s language syntax, memory management, control flow, and APIs without classes in the previous chapters. This final chapter is about what it feels like to use Odin day to day. We will look at these three things: distinct types, testing, and the build workflow/toolchain.
Let’s dive right in.
Distinct Types
In most languages, when you define a value as an f64, it is a f64, whether it holds a temperature in Celsius or Fahrenheit. Assign one to the other and the compiler says nothing, even though the two values mean different things. That kind of mix-up compiles fine and fails later in production, and your static type checkers will never catch it.1
What if we could define two specific types whose underlying semantics are the same as f64? That way you get static type checking and prevent bugs caused by that kind of mix-up.
Odin’s answer to that is a distinct type. It creates a new type with the same underlying semantics as the backing type.2 Here’s how to define distinct types for the temperature values:
Celsius :: distinct f64
Fahrenheit :: distinct f64
Because the compiler treats them as different types, this will not compile:
boiling: Celsius = 100
temp: Fahrenheit = boiling
Instead, you’d get a compilation error:
Error: Cannot assign value 'boiling' of type 'Celsius' to 'Fahrenheit' in a variable declaration
temp: Fahrenheit = boiling
^~~~~~^
Suggestion: The expression may be directly casted to type Fahrenheit
Notice the last line? The compiler does not just reject the code. It tells you how to satisfy the type system; that is to cast the value. But a cast only changes the type. It does not convert Celsius to Fahrenheit. For that, we need the actual conversion formula.
The conversion procedure would look like this:
to_fahrenheit :: proc(c: Celsius) -> Fahrenheit {
return Fahrenheit(f64(c) * 9.0/5.0 + 32.0)
}
It helps to compare distinct to a type alias. For example, Meters :: f64 is just another name for f64 and the two are fully interchangeable. So do not confuse aliases with distinct types.
Testing Without a Framework
Odin ships with a test runner in its toolchain. There is nothing to install or configure separately. This is very different from many other languages where you have to install an external library or tool to write, run, and manage tests.
Each test case is a procedure that requires the @(test) attribute and must accept only one argument: ^testing.T. That is how the compiler knows which procedures to send to the test runner. Let’s explore how it works by writing tests for the exporter code from the previous chapter.
Create a file named exporters_test.odin in the same directory as exporters.odin, and paste in the following code:
package main
import "core:strings"
import "core:testing"
@(test)
test_export_json_compact :: proc(t: ^testing.T) {
report := Report{title = "Systems Notes", author = "A. Lovelace", pages = 42}
out := export_json(Json_Exporter{pretty = false}, report)
defer delete(out)
testing.expect_value(t, out, `{"title": "Systems Notes", "author": "A. Lovelace", "pages": 42}`)
}
@(test)
test_export_text_uppercase :: proc(t: ^testing.T) {
report := Report{title = "Systems Notes", author = "A. Lovelace", pages = 42}
out := export_text(Text_Exporter{uppercase = true}, report)
defer delete(out)
testing.expect(t, strings.contains(out, "SYSTEMS NOTES"))
}
The ^testing.T is a pointer to a struct defined by the core:testing package. In the test procedures, the expect and expect_value procedures are used to verify that the result matches the expected value. The testing package provides more assertion helpers and you can find them at pkg.odin-lang.org/core/testing/.
Run the tests using the command odin test . and you should see output like this:
[INFO ] --- [2026-09-24 15:40:41] Starting test runner with 2 threads. Set with -define:ODIN_TEST_THREADS=n.
[INFO ] --- [2026-09-24 15:40:41] The random seed sent to every test is: 27567609318961. Set with -define:ODIN_TEST_RANDOM_SEED=n.
[INFO ] --- [2026-09-24 15:40:41] Memory tracking is enabled. Tests will log their memory usage if there's an issue.
[INFO ] --- [2026-09-24 15:40:41] < Final Mem/ Total Mem> < Peak Mem> (#Free/Alloc) :: [package.test_name]
main [|| ] 2 :: [package done]
Finished 2 tests in 93µs. All tests were successful.
The tests ran successfully.
Did you notice this line in the output: Memory tracking is enabled. Tests will log their memory usage if there's an issue? The test runner tracks memory by default3, which makes it useful for catching leaked allocations during tests.
Memory Safety Testing
Let’s explore briefly what memory tracking gives us in tests with a simple test that deliberately leaks an allocation. Create a file named leak_test.odin, paste the code below in it and run that file:
@(test)
test_scratch_buffer :: proc(t: ^testing.T) {
scratch := make([]u8, 24)
// oops: no delete(scratch)
testing.expect_value(t, len(scratch), 24)
}
You should get the output below:
[WARN ] --- [2026-09-24 15:49:09] < 24B/ 24B> < 24B> ( 0/ 1) :: main.test_scratch_buffer
+++ leak 24B @ 0x150420038 [exporter.odin:49:test_scratch_buffer()]
Finished 1 test in 273µs. The test was successful.
The test passes, but the runner still reports the leak and points at the exact line that allocated it. The location in the trace is the allocation site, so if a leak comes from inside a library call, the trace will point there, and you can follow it to see who allocated what you forgot to free. After the memory allocation lesson in chapter 2, this is a useful safety net for when you forget a delete in a test or production code under test.
The default behaviour of the runner is that it reports it ran successfully. That makes the warning easy to miss. You can tell it to fail when there’s a memory leak by using the flag ODIN_TEST_FAIL_ON_BAD_MEMORY=true. So running odin test . -define:ODIN_TEST_FAIL_ON_BAD_MEMORY=true should fail the test run.
No Build Files
You may have noticed something missing from this series: a build configuration file. We did not need a Makefile, Cargo.toml, package.json, or .csproj. A directory and its files make up a package, as you learned in chapter 1, and the three commands you have used — odin run ., odin build ., and now odin test . — cover most of your daily workflow.
Compiler options are passed as command-line flags when you need them, for example, -out:my_binary to name the output, -o:speed to optimise, -define:KEY=value to set compile-time constants like ODIN_TEST_FAIL_ON_BAD_MEMORY.
For bigger projects, you may need to manage dependencies, but Odin doesn’t have a package manager. A common recommendation is to vendor dependencies. I’ve also seen people build their own package managers.
That’s a Wrap
This chapter covered the working feel of the language. A distinct type gives a primitive its own identity, so the compiler stops you from mixing values that mean different things, and conversions between them are explicit and visible. The test runner is built into the toolchain and its memory tracking catches leaks even when tests pass.
Here’s a quick summary:
| When you need… | Reach for… |
|---|---|
| A primitive with its own identity, compiler-checked | a distinct type |
| A shorter name for a type | a plain alias (Meters :: f64) |
| To run the program | odin run . |
| To produce a binary | odin build . |
| To run tests | odin test . |
| To catch leaked allocations in tests | the testing package’s memory tracking |
Looking back at the whole series, Chapter 1 got you compiling and running simple programs. Chapter 2 made memory management manageable. Chapter 3 made control flow visible, and Chapter 4 built APIs without classes. This chapter showed what everyday work in Odin feels like.
That was the promise of Learn Odin in an Hour. Your hour is up 👮🏽♂️.
Here are a few more resources for going deeper:
- pkg.odin-lang.org — the full core and vendor package documentation
- core:mem — more allocators, including an arena and a tracking allocator, for when you outgrow the two defaults from chapter 2.
- The official FAQ, the language overview, and the examples repository for complete, runnable programs.
- The community on Discord and the forum — the forum is where some readers of this very series sent me detailed, generous feedback that made it better.
Thank you for reading and coding along. If something in this series is wrong, unclear, or could be said better, I’d like to hear from you. The first round of reader feedback already made these chapters better than I could have done alone.
Notes & References
Footnotes
-
Unit mix-ups are not a theoretical problem. In 1999, NASA lost the Mars Climate Orbiter because Lockheed Martin generated thruster impulse data in imperial pound-force seconds while NASA’s Jet Propulsion Laboratory (JPL) navigation software expected metric newton-seconds, and the trajectory was off by a factor of 4.45. To be fair, types alone would not have prevented that cross-system mismatch, but distinct unit types can prevent similar mistakes within a single codebase. ↩
-
Distinct types — Odin overview. A distinct type has the same underlying semantics as its base type, but is not interchangeable with it. ↩
-
The runner also shows its configuration flags in the output:
-define:ODIN_TEST_THREADS=nfor parallelism and-define:ODIN_TEST_RANDOM_SEED=nfor the random seed. ↩