Hello World & Building
Hello, World
Odin has no top-level statements: every file names a
package and execution begins at main. The :: operator binds a compile-time constant, so main :: proc() reads as "main is a procedure" — the same operator declares types and constants.Console.WriteLine("Hello, World!"); package main
import "core:fmt"
main :: proc() {
fmt.println("Hello, World!")
} This is roughly the
static void Main ceremony C# spent two versions removing, and Odin keeps it deliberately: a file is a compilation unit with no implicit entry point, so nothing runs merely because it was loaded.No csproj, no NuGet
Odin imports by collection path:
core: is the standard library shipped with the compiler and vendor: is the bundled third-party set (SDL, raylib, OpenGL). Everything else lives in your own tree.// The .NET build system is a large part of the language's
// value, and it is everywhere:
// dotnet new console
// dotnet add package Newtonsoft.Json
// dotnet build -c Release
// dotnet test
// dotnet publish -r linux-x64 --self-contained
// nuget.org carries well over 400,000 packages.
Console.WriteLine("MSBuild resolved every dependency for you"); // A DIRECTORY is a package. There is no project file, no
// lockfile, no version resolution, and no central registry.
// odin build . compile a directory
// odin run . -o:speed optimized
// odin test . run the test procedures
//
// Dependencies are VENDORED — copied into your tree or added
// as a git submodule, then imported by path:
// import mylibrary "shared/mylibrary"
// core: and vendor: ship with the compiler.
package main
import "core:fmt"
main :: proc() {
fmt.println("you resolved every dependency yourself")
} This is the largest genuine loss on the page and it should inform whether Odin fits the job at all. If your instinct on any new problem is to search nuget.org, no language feature compensates for that instinct being unavailable — though for the game and graphics work Odin targets, the dependency list is usually short and mostly C libraries anyway.
var, const, and declaration order
Odin puts the type after the name, which is what makes
:= a single inference operator rather than an assignment with punctuation. Locals are mutable by default and there is no readonly for a local — immutability applies to constants and to parameters.var name = "Ada"; // inferred
int age = 36; // explicit
const int MaximumRetries = 3; // compile-time constant
// A local cannot be readonly; only fields can, and only as
// 'readonly' (initialized once) or 'const' (compile time).
Console.WriteLine($"{name} {age} {MaximumRetries}"); package main
import "core:fmt"
// :: is a compile-time constant: no storage, no address,
// substituted at every use. It declares values, types, and
// procedures alike.
MAXIMUM_RETRIES :: 3
main :: proc() {
// Full form reads left to right: name : Type = value
name: string = "Ada"
// Drop the type and := infers it.
age := 36
fmt.println(name, age, MAXIMUM_RETRIES)
// MAXIMUM_RETRIES = 4 // Error: cannot assign to a constant
} The
:: constant is genuinely closer to C#'s const than to static readonly: it has no runtime storage at all. Note also that Odin has no static anything, because there is no class for a member to be static on.Compilation model
Odin has no runtime in the .NET sense — no CLR, no type loader, no JIT. What the compiler emits is the whole program, and a modest project builds in a fraction of a second.
// C# compiles to IL, which the CLR JITs at runtime — or
// which ReadyToRun/NativeAOT precompiles. A build involves
// MSBuild, NuGet restore, Roslyn, and (for AOT) a linker.
var start = DateTime.UtcNow;
var total = 0;
for (var index = 0; index < 1_000_000; index++) total += index;
Console.WriteLine(total);
Console.WriteLine($"the JIT warmed up somewhere in there: {start.Year}"); // Odin compiles straight to a native binary through LLVM.
// There is no IL, no JIT, no runtime to install, and no
// startup warm-up — the binary is the program.
package main
import "core:fmt"
main :: proc() {
total := 0
for index in 0 ..< 1_000_000 {
total += index
}
fmt.println(total)
fmt.println("no JIT, no tiered compilation, no warm-up")
} The trade is that everything the CLR does for you at runtime has to be decided at compile time instead. You gain a binary with no startup cost and no deployment prerequisites; you lose runtime code generation, assembly loading, and every tool that depends on inspecting IL.
Types & Values
Everything is a value type
Odin has no
class and therefore no reference types. A struct is a plain layout of fields with no object header and no vtable, assignment copies it, and sharing requires writing ^Point and &value.var first = new PointClass { X = 1, Y = 2 };
var second = first; // same object
second.X = 99;
Console.WriteLine($"{first.X} {second.X}"); // 99 99
var third = new PointStruct { X = 1, Y = 2 };
var fourth = third; // a copy
fourth.X = 99;
Console.WriteLine($"{third.X} {fourth.X}"); // 1 99
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; } package main
import "core:fmt"
Point :: struct {
x: int,
y: int,
}
main :: proc() {
// There is only one kind. Assignment always copies.
first := Point{1, 2}
second := first
second.x = 99
fmt.println(first.x, second.x) // 1 99
// Sharing is explicit: take a pointer.
shared := &first
shared.x = 42
fmt.println(first.x) // 42
} C# makes you choose
class or struct per type and then live with the aliasing rules that choice implies — which is why "is this a reference or a copy?" is a question you ask about every unfamiliar type. In Odin the answer is always the same, and the exception is spelled out at the call site.No null, and no NullReferenceException
Odin zero-initializes everything unless you write
= --- to opt out. Because struct fields hold values rather than references, "the field is missing" is not a state that can occur.#nullable enable
string? maybeName = null;
Console.WriteLine(maybeName?.Length ?? -1);
// The nullable annotation is erased at runtime and only
// produces warnings, so this still compiles and still throws:
string definitelyName = null!;
try
{
Console.WriteLine(definitelyName.Length);
}
catch (NullReferenceException)
{
Console.WriteLine("the billion-dollar mistake, at runtime");
} package main
import "core:fmt"
Account :: struct {
balance: int,
owner: string,
active: bool,
}
main :: proc() {
// Every declaration is zero-initialized. A string's zero
// value is the empty string, not a null reference.
account: Account
fmt.println(account.balance, account.owner == "", account.active)
// Opt out explicitly for a buffer you are about to fill:
scratch: [4]int = ---
scratch = {1, 2, 3, 4}
fmt.println(scratch)
// Pointers can still be nil, and that is the only place
// the question arises.
pointer: ^Account
fmt.println(pointer == nil)
} C# 8's nullable reference types are annotations the runtime never sees:
null! silences the compiler and the NullReferenceException still arrives. Odin removes the category instead of annotating it — and where absence is genuinely meaningful, Maybe(T) in the unions section makes it part of the type.Numbers and overflow
Signed overflow in Odin is defined to wrap rather than being undefined behavior, so the result is predictable — which is not the same as being harmless. There is no
checked block and no OverflowException.int biggest = int.MaxValue;
Console.WriteLine(unchecked(biggest + 1)); // wraps, by default
try
{
Console.WriteLine(checked(biggest + 1));
}
catch (OverflowException)
{
Console.WriteLine("checked() turns it into an exception");
}
decimal money = 19.99m; // exact decimal, no binary error
Console.WriteLine(money * 3);
Console.WriteLine(0.1 + 0.2); package main
import "core:fmt"
main :: proc() {
// Sized types are spelled out: i8 i16 i32 i64 i128,
// u8 u16 u32 u64 u128, f16 f32 f64. int is a machine word.
biggest := max(i64)
one: i64 = 1
fmt.println(biggest + one) // wraps, and the wrap is DEFINED
small: u8 = 200
fmt.println(small + small) // 400 wraps to 144
// There is no decimal type. Money is an integer count of
// the smallest unit, which is what banks do anyway.
cents := 1999
fmt.println(cents * 3, f64(cents * 3) / 100)
fmt.println(0.1 + 0.2)
} The missing piece for anyone porting business logic is
decimal. C# gives you exact base-ten arithmetic as a primitive; Odin has i128 and nothing else, so amounts become integer counts of cents and the rounding rules become yours to write.No implicit conversions
distinct T creates a genuinely separate type with the same representation, so a Money cannot be added to an int by accident. Every conversion is written as Type(value).int count = 42;
double ratio = count; // widening, implicit
long wider = count; // implicit
Console.WriteLine(ratio / 5);
// User-defined implicit operators exist too:
Money price = 100;
Console.WriteLine(price.Amount);
readonly struct Money
{
public int Amount { get; }
public Money(int amount) => Amount = amount;
public static implicit operator Money(int amount) => new(amount);
} package main
import "core:fmt"
// A distinct type is a real, separate type — no implicit
// conversion in either direction, and no operator overloading
// to add one.
Money :: distinct int
main :: proc() {
count := 42
// ratio := count / 5.0 // Error: mismatched types
ratio := f64(count) / 5.0
fmt.println(ratio)
wider := i64(count)
fmt.println(wider)
price: Money = 100
// total := price + count // Error: Money and int differ
total := price + Money(count)
fmt.println(total, int(total))
} C# hides widening conversions and lets a library author add more with
implicit operator, which is convenient right up to the point where an unintended conversion silently changes what an expression means. Odin removes the mechanism entirely — there is no operator overloading of any kind, which also means + always means addition.Span<T> becomes the ordinary case
An Odin slice
[]T is a pointer and a length — the same two words as Span<T>. The difference is that it is an ordinary type with no ref struct restrictions, so it can live in a field, be returned, or be stored in another slice.int[] numbers = { 1, 2, 3, 4, 5 };
// Span<T> was added so you could pass a view of a buffer
// without copying it. It is a ref struct, so it cannot be
// stored in a field, captured, or used in async methods.
Span<int> window = numbers.AsSpan(1, 3);
window[0] = 99;
Console.WriteLine(string.Join(",", numbers));
Span<int> scratch = stackalloc int[4];
scratch[0] = 7;
Console.WriteLine(scratch[0]); package main
import "core:fmt"
main :: proc() {
// A fixed array lives where it was declared — on the
// stack here, no allocation.
numbers := [5]int{1, 2, 3, 4, 5}
// A slice is a pointer plus a length: exactly Span<T>,
// except it is the NORMAL way to pass a collection and
// carries no special restrictions at all.
window := numbers[1:4]
window[0] = 99
fmt.println(numbers)
// Slices are ordinary values: store them in structs,
// return them, keep them in other slices.
Buffer :: struct {
data: []int,
}
buffer := Buffer{data = numbers[:]}
fmt.println(len(buffer.data))
} This is one of the clearest wins for a .NET reader, because
Span<T> is the shape you already wanted everywhere and could only use in the places the runtime permitted. The cost is that nothing checks whether the memory behind a slice is still alive — the borrow rules ref struct encodes are now yours to keep.Strings
string is not a class
Odin strings are UTF-8 rather than UTF-16, and a
string is two words with no object header. Note the defer delete on every result: procedures that build a new string allocate, and in a language with no collector the caller owns what it asked for.var greeting = "Hello, World";
Console.WriteLine(greeting.ToUpper());
Console.WriteLine(greeting.Length);
Console.WriteLine(greeting.Contains("World"));
Console.WriteLine(string.Join("|", greeting.Split(", ")));
// A System.String is a heap object with a header, a length,
// and UTF-16 code units — plus about 40 instance methods.
Console.WriteLine(greeting.GetType().FullName); package main
import "core:fmt"
import "core:strings"
main :: proc() {
greeting := "Hello, World"
// A string is a pointer and a length over UTF-8 BYTES.
// It has no methods; everything is a free procedure.
upper := strings.to_upper(greeting)
defer delete(upper)
fmt.println(upper)
fmt.println(len(greeting)) // bytes, and len is a builtin
fmt.println(strings.contains(greeting, "World"))
pieces := strings.split(greeting, ", ")
defer delete(pieces)
joined := strings.join(pieces, "|")
defer delete(joined)
fmt.println(joined)
} Which procedures allocate is the thing to learn here, and it is entirely predictable — anything returning a fresh
string or slice does, anything only reading (like strings.contains) does not. That distinction is invisible in C# because the GC absorbs it.String interpolation
The
fmt naming rule is worth learning at once: print* writes to stdout, tprint* returns a string in the temporary allocator, and aprint* returns one the caller must delete. The prefix is how you choose the lifetime.var name = "Ada";
var age = 36;
Console.WriteLine($"{name} is {age} years old");
Console.WriteLine($"{name,-6}|{1.7,5:F2}|");
// Interpolated strings became a compiler feature in C# 10:
// the handler builds the result without intermediate strings.
var message = $"Next year: {age + 1}";
Console.WriteLine(message); package main
import "core:fmt"
main :: proc() {
name := "Ada"
age := 36
// There is no interpolation syntax. Formatting is a call,
// and %v prints any value sensibly.
fmt.printf("%v is %v years old\n", name, age)
fmt.printf("%-6s|%5.2f|\n", name, 1.7)
// Build a string instead of printing one:
message := fmt.tprintf("Next year: %v", age + 1)
fmt.println(message)
} %v is the closest thing to ToString(), and it works on structs, slices, maps, enums, and unions with no code from you — the compiler emits type information and fmt walks it. Where C# calls a virtual method the type overrides, Odin reads the layout.StringBuilder
strings.Builder owns a growable byte buffer, and strings.to_string returns a string that points into that buffer instead of copying it — so the builder must outlive every use of the result.var builder = new StringBuilder();
foreach (var word in new[] { "never", "gonna", "give" })
{
builder.Append(word);
builder.Append(' ');
}
Console.WriteLine(builder.ToString().TrimEnd());
// Strings are immutable, so += in a loop allocates each time.
var slow = "";
for (var index = 0; index < 3; index++) slow += index;
Console.WriteLine(slow); package main
import "core:fmt"
import "core:strings"
main :: proc() {
builder := strings.builder_make()
defer strings.builder_destroy(&builder)
words := []string{"never", "gonna", "give"}
for word in words {
strings.write_string(&builder, word)
strings.write_byte(&builder, ' ')
}
assembled := strings.to_string(builder)
fmt.println(strings.trim_space(assembled))
} The mechanism is the same one
StringBuilder uses; the difference is that builder_destroy is a line you write and the returned string is a view rather than a copy. That view is faster and is also a lifetime you now have to think about.char and rune
A
rune is a 32-bit Unicode code point, so a single one holds any character — there is no surrogate pair to trip over. Iterating a string decodes UTF-8 as it goes and yields (rune, byte offset), the value first.var text = "héllo";
Console.WriteLine(text.Length); // 5 UTF-16 units
Console.WriteLine(Encoding.UTF8.GetByteCount(text)); // 6 bytes
foreach (var character in text)
{
Console.Write($"{character} ");
}
Console.WriteLine();
// A char is 16 bits, so anything outside the BMP takes two
// of them and this loop sees halves of a surrogate pair.
Console.WriteLine("🐦".Length); package main
import "core:fmt"
import "core:unicode/utf8"
main :: proc() {
text := "héllo"
fmt.println(len(text)) // 6 — BYTES
fmt.println(utf8.rune_count(text)) // 5 — characters
// Iterating decodes UTF-8 and yields (rune, byte offset).
// The VALUE comes first.
for character, offset in text {
fmt.printf("%v@%v ", character, offset)
}
fmt.println()
// A rune is a full 32-bit code point, so nothing is split.
fmt.println(utf8.rune_count("🐦"))
} C#'s
char is a UTF-16 code unit, which is why "🐦".Length is 2 and a foreach over that string sees two halves of nothing. Odin's split is honest about the two units that actually matter — bytes for storage, runes for characters — and never invents a third.Parsing
Multiple return values are a language feature in Odin, not an
out parameter — which is why the TryParse shape needs no special syntax and is simply how a fallible procedure is written.Console.WriteLine(int.Parse("42"));
if (int.TryParse("not a number", out var parsed))
Console.WriteLine(parsed);
else
Console.WriteLine("TryParse reported failure");
try
{
int.Parse("not a number");
}
catch (FormatException)
{
Console.WriteLine("Parse throws instead");
} package main
import "core:fmt"
import "core:strconv"
main :: proc() {
// There is only the TryParse shape. Nothing throws.
count, count_ok := strconv.parse_int("42")
fmt.println(count, count_ok)
bad, bad_ok := strconv.parse_int("not a number")
fmt.println(bad, bad_ok) // 0 false
if !bad_ok {
fmt.println("the caller has to notice")
}
ratio, _ := strconv.parse_f64("3.14")
fmt.println(ratio)
} C# ships both shapes and the throwing one is the shorter to type, which is why it turns up in code paths that then need a
try. Odin ships only the reporting shape, so the failure always arrives as a value at the point it happened.Collections
List<T> becomes [dynamic]T
append, pop, ordered_remove, and clear are builtins taking a pointer to the dynamic array so they can reallocate its buffer. Read-only helpers take a plain slice, which is why slice.contains is called with numbers[:].var numbers = new List<int> { 1, 2, 3 };
numbers.Add(4);
numbers.AddRange(new[] { 5, 6 });
Console.WriteLine(string.Join(",", numbers));
Console.WriteLine(numbers.Count);
Console.WriteLine(numbers.Capacity >= numbers.Count);
numbers.RemoveAt(0);
Console.WriteLine(numbers[0]);
Console.WriteLine(numbers.Contains(6)); package main
import "core:fmt"
import "core:slice"
main :: proc() {
numbers: [dynamic]int
defer delete(numbers)
append(&numbers, 1, 2, 3)
append(&numbers, 4)
more := []int{5, 6}
append(&numbers, ..more)
fmt.println(numbers, len(numbers), cap(numbers))
ordered_remove(&numbers, 0)
fmt.println(numbers[0])
fmt.println(slice.contains(numbers[:], 6))
} The growth strategy is the same amortized doubling
List<T> uses. The two differences are that the buffer needs an explicit delete, and that numbers[:] hands out a view of it — passing the collection costs two words rather than a reference plus indirection.Arrays
A fixed array's length is part of its type —
[3]int and [4]int are different types — so it copies on assignment and len is known at compile time. Taking [:] produces the aliasing view.int[] fixedSize = new int[3];
fixedSize[0] = 1;
int[] initialized = { 1, 2, 3 };
int[,] grid = new int[2, 3];
grid[1, 2] = 7;
Console.WriteLine(string.Join(",", initialized));
Console.WriteLine(initialized.Length);
Console.WriteLine(grid[1, 2]);
// Arrays are reference types: this is an alias, not a copy.
var alias = initialized;
alias[0] = 99;
Console.WriteLine(initialized[0]); package main
import "core:fmt"
main :: proc() {
// The length is part of the TYPE, and assigning copies.
initialized := [3]int{1, 2, 3}
copied := initialized
copied[0] = 99
fmt.println(initialized[0], copied[0]) // 1 99
fmt.println(len(initialized), size_of(initialized))
// Multidimensional arrays nest:
grid: [2][3]int
grid[1][2] = 7
fmt.println(grid[1][2])
// A slice of it is the aliasing view, spelled out:
view := initialized[:]
view[0] = 99
fmt.println(initialized[0]) // 99
} C# arrays are reference types, so
var alias = array shares the storage and every helper that takes an array can mutate the caller's data. Odin separates the two ideas: the array is the storage and copies, the slice is the view and aliases, and which one you meant is visible in the code.Dictionary<K,V> becomes map
Keep two names straight:
delete_key removes one entry, while delete frees the whole map — so the defer delete(ages) on line two is the map's teardown. Iteration yields (key, value).var ages = new Dictionary<string, int>
{
["ada"] = 36,
["grace"] = 45,
};
ages["alan"] = 41;
Console.WriteLine(ages["ada"]);
Console.WriteLine(ages.ContainsKey("grace"));
Console.WriteLine(ages.TryGetValue("nobody", out var missing) ? missing : -1);
foreach (var (name, age) in ages)
Console.WriteLine($"{name}: {age}");
ages.Remove("alan");
Console.WriteLine(ages.Count); package main
import "core:fmt"
main :: proc() {
ages := make(map[string]int)
defer delete(ages)
ages["ada"] = 36
ages["grace"] = 45
ages["alan"] = 41
fmt.println(ages["ada"])
fmt.println("grace" in ages)
// A missing key returns the ZERO value, not an exception.
// Ask for the second result when absence matters.
age, found := ages["nobody"]
fmt.println(age, found) // 0 false
for name, value in ages {
fmt.printf("%v: %v\n", name, value)
}
delete_key(&ages, "alan")
fmt.println(len(ages))
} Indexing a
Dictionary with a missing key throws; indexing an Odin map returns the value type's zero. That makes a map of counters work without TryGetValue ceremony, and it makes the second return value the only honest way to distinguish "stored zero" from "absent".HashSet and [Flags]
bit_set[T] packs a set of enum members into one integer, so membership is a bit test and &, |, - are genuine set operations. card is the population count.var seen = new HashSet<string> { "ada", "grace" };
seen.Add("ada");
Console.WriteLine(seen.Count);
Console.WriteLine(seen.Contains("ada"));
var granted = Permission.Read | Permission.Write;
Console.WriteLine(granted.HasFlag(Permission.Read));
Console.WriteLine(granted);
[Flags]
enum Permission { None = 0, Read = 1, Write = 2, Execute = 4 } package main
import "core:fmt"
Permission :: enum { Read, Write, Execute }
Permissions :: bit_set[Permission]
main :: proc() {
// bit_set is [Flags] built into the type system: one
// machine word, with real set operators.
granted: Permissions = {.Read, .Write}
required: Permissions = {.Write, .Execute}
fmt.println(.Read in granted)
fmt.println(card(granted))
fmt.println(granted & required) // intersection
fmt.println(granted | required) // union
fmt.println(granted - required) // difference
// For arbitrary keys, a map to an empty struct is the
// idiom — the value occupies zero bytes.
seen := make(map[string]struct{})
defer delete(seen)
seen["ada"] = {}
seen["grace"] = {}
fmt.println(len(seen), "ada" in seen)
} C#'s
[Flags] is a convention layered on an integer enum: you assign the powers of two by hand, HasFlag boxes unless the JIT saves you, and nothing stops (Permission)99. The bit_set is a real type where the bit positions are the compiler's job.No IEnumerable
for is a keyword that knows about ranges, slices, maps, and strings — it cannot be extended to your own type, because there is no interface for it to dispatch through.IEnumerable<int> Squares(int count)
{
for (var index = 1; index <= count; index++)
yield return index * index; // lazy, resumable
}
foreach (var square in Squares(4))
Console.Write($"{square} ");
Console.WriteLine();
// Any type implementing IEnumerable<T> works with foreach,
// LINQ, and collection initializers.
Console.WriteLine(Squares(4).Take(2).Sum()); package main
import "core:fmt"
// There is no IEnumerable, no yield return, and no iterator
// protocol to implement. Produce the values...
squares :: proc(count: int, into: ^[dynamic]int) {
for index in 1 ..= count {
append(into, index * index)
}
}
// ...or write a procedure the caller drives, which is the
// closest thing to a lazy sequence.
next_square :: proc(index: int) -> (value: int, ok: bool) {
if index > 4 {
return 0, false
}
return index * index, true
}
main :: proc() {
values: [dynamic]int
defer delete(values)
squares(4, &values)
fmt.println(values)
total := 0
for index := 1; ; index += 1 {
value, ok := next_square(index)
if !ok { break }
if index > 2 { break }
total += value
}
fmt.println(total)
} Losing
yield return means losing the compiler-generated state machine that made lazy sequences cheap to write. What replaces it is either filling a buffer the caller owns (fast, eager, no allocation surprises) or a hand-rolled cursor procedure — both of which are what the C# compiler was generating for you anyway.LINQ & Lambdas
There is no LINQ
LINQ needs closures, and Odin has none — a procedure literal is a bare code pointer with no captured environment. Everything built on that foundation, from
Where to query syntax to expression trees, is therefore absent.var numbers = new[] { 1, 2, 3, 4, 5, 6 };
var result = numbers
.Where(number => number % 2 == 0)
.Select(number => number * number)
.Sum();
Console.WriteLine(result);
Console.WriteLine(string.Join(",", numbers.OrderByDescending(n => n).Take(3)));
Console.WriteLine(numbers.Any(n => n > 5));
Console.WriteLine(numbers.GroupBy(n => n % 2).Count()); package main
import "core:fmt"
main :: proc() {
numbers := []int{1, 2, 3, 4, 5, 6}
// The whole chain collapses into one loop over the data.
sum_of_even_squares := 0
for number in numbers {
if number % 2 == 0 {
sum_of_even_squares += number * number
}
}
fmt.println(sum_of_even_squares)
any_large := false
for number in numbers {
if number > 5 {
any_large = true
break
}
}
fmt.println(any_large)
} This is the biggest day-to-day adjustment on the page, larger than losing the garbage collector, because it touches every method you write rather than one subsystem. The honest consolation is real: the C# version allocates an iterator per stage and pays a delegate call per element, and the Odin version is one pass with nothing allocated.
No capturing lambdas
Writing the captured state as a struct is more typing, and it is also exactly what Roslyn generates behind a capturing lambda — a heap-allocated display class holding the captured variables.
var multiplier = 3;
// The lambda CAPTURES multiplier — the compiler generates a
// closure class holding it on the heap.
Func<int, int> scale = value => value * multiplier;
Console.WriteLine(scale(5));
multiplier = 10;
Console.WriteLine(scale(5)); // sees the new value
var counter = 0;
Action increment = () => counter++;
increment(); increment();
Console.WriteLine(counter); package main
import "core:fmt"
// A procedure literal cannot refer to a surrounding local.
// State a C# lambda would capture becomes an explicit struct.
Scaler :: struct {
multiplier: int,
}
scaler_apply :: proc(scaler: Scaler, value: int) -> int {
return value * scaler.multiplier
}
Counter :: struct {
count: int,
}
counter_increment :: proc(counter: ^Counter) {
counter.count += 1
}
main :: proc() {
scaler := Scaler{multiplier = 3}
fmt.println(scaler_apply(scaler, 5))
scaler.multiplier = 10
fmt.println(scaler_apply(scaler, 5))
counter := Counter{}
counter_increment(&counter)
counter_increment(&counter)
fmt.println(counter.count)
} The C# version quietly demonstrates why that matters:
scale captured the variable, not its value, so changing multiplier changed what scale does. In Odin the state is a value you passed, so there is no shared mutable binding to be surprised by, and no allocation you did not ask for.Delegates become procedure values
Odin does have first-class procedures —
proc(value: int) -> int is a type like any other. What it lacks is the environment: no captured locals, no bound this, and no invocation list.Func<int, int> timesTen = value => value * 10;
Console.WriteLine(ApplyTwice(3, timesTen));
// Delegates are multicast and can be combined:
Action<string> log = message => Console.WriteLine($"a: {message}");
log += message => Console.WriteLine($"b: {message}");
log("both handlers run");
int ApplyTwice(int value, Func<int, int> operation) =>
operation(operation(value)); package main
import "core:fmt"
// A procedure value has a type: proc(int) -> int. It is a
// bare code pointer — no target object, no invocation list.
apply_twice :: proc(value: int, operation: proc(value: int) -> int) -> int {
return operation(operation(value))
}
times_ten :: proc(value: int) -> int {
return value * 10
}
main :: proc() {
fmt.println(apply_twice(3, times_ten))
// A literal works too, as long as it captures nothing.
fmt.println(apply_twice(3, proc(value: int) -> int {
return value + 1
}))
// Multicast is a slice of procedures you loop over.
handlers := []proc(message: string){
proc(message: string) { fmt.println("a:", message) },
proc(message: string) { fmt.println("b:", message) },
}
for handler in handlers {
handler("both handlers run")
}
} This covers most of what non-capturing delegates do — a comparator, a callback, a strategy chosen at runtime. Events are the visible gap: a C#
event is a multicast delegate with subscribe and unsubscribe built in, and here it is a slice you manage yourself.Sorting with a comparator
slice.sort_by sorts in place, which is why the example clones first — and the clone then needs its own delete. The comparator returns a bool ("strictly less"), not the three-way int that IComparer wants.var words = new List<string> { "cherry", "apple", "banana" };
words.Sort();
Console.WriteLine(string.Join(",", words));
words.Sort((left, right) => left.Length.CompareTo(right.Length));
Console.WriteLine(string.Join(",", words));
Console.WriteLine(string.Join(",", words.OrderByDescending(word => word)));
Console.WriteLine(words.Any(word => word.StartsWith("b"))); package main
import "core:fmt"
import "core:slice"
import "core:strings"
main :: proc() {
source := []string{"cherry", "apple", "banana"}
words := slice.clone(source)
defer delete(words)
slice.sort(words)
fmt.println(words)
// The comparator answers "does a come strictly before b?"
slice.sort_by(words, proc(a, b: string) -> bool {
return len(a) < len(b)
})
fmt.println(words)
slice.reverse_sort(words)
fmt.println(words)
fmt.println(slice.any_of_proc(words, proc(word: string) -> bool {
return strings.has_prefix(word, "b")
}))
} A comparator is the ideal case for procedure values, because it genuinely needs nothing from the surrounding scope. The moment you want
OrderBy(item => item.DistanceTo(origin)) where origin is a local, the pattern breaks and the loop comes back.Control Flow
if and the ternary
Odin's conditional expression is
value if condition else other rather than condition ? value : other, and the condition must be an actual bool — there is no conversion from an integer or a pointer.var temperature = 22;
if (temperature > 30)
Console.WriteLine("hot");
else if (temperature > 15)
Console.WriteLine("mild");
else
Console.WriteLine("cold");
var label = temperature > 20 ? "warm" : "cool";
Console.WriteLine(label);
string? cached = null;
Console.WriteLine(cached ?? "default"); package main
import "core:fmt"
main :: proc() {
temperature := 22
// Braces are mandatory; parentheses are not used.
if temperature > 30 {
fmt.println("hot")
} else if temperature > 15 {
fmt.println("mild")
} else {
fmt.println("cold")
}
// The ternary reads in English word order.
label := "warm" if temperature > 20 else "cool"
fmt.println(label)
// An if may open with a statement scoped to the branch:
if adjusted := temperature + 2; adjusted > 20 {
fmt.println("adjusted is", adjusted)
}
} The
if statement; condition form is the piece C# does not have: the variable exists only inside the branch, so a value used for one test cannot leak into the rest of the method. C#'s ?? maps to or_else, which the optionals section covers.switch and pattern matching
An empty
case: is the default arm, and Odin borrows C#'s no-fall-through rule without requiring break to enforce it — fallthrough is the explicit opt-in. Ranges use ..< and ..=.var value = 7;
var description = value switch
{
0 => "zero",
> 0 and < 5 => "small",
>= 5 and <= 10 => "medium",
_ => "large",
};
Console.WriteLine(description);
object boxed = "text";
switch (boxed)
{
case int number: Console.WriteLine($"int {number}"); break;
case string text when text.Length > 2: Console.WriteLine($"long string {text}"); break;
default: Console.WriteLine("something else"); break;
} package main
import "core:fmt"
main :: proc() {
value := 7
// switch is a statement; arms do NOT fall through, so
// there is no break — and no switch expression.
description: string
switch value {
case 0:
description = "zero"
case 1 ..< 5:
description = "small"
case 5 ..= 10:
description = "medium"
case:
description = "large"
}
fmt.println(description)
// A switch may open with a statement, and an empty
// expression makes the arms plain conditions:
switch text := "text"; {
case len(text) > 2:
fmt.println("long string", text)
case:
fmt.println("short")
}
} What is missing is the switch expression: Odin's
switch is a statement, so the C# habit of assigning its result needs a declared variable first. Type patterns move to switch in over a tagged union, which the interfaces section covers, and property patterns have no equivalent at all.Loops
There is no
++ or -- in Odin — index += 1 is the increment — and no do/while. A bare for with a break covers the run-at-least-once case.for (var index = 0; index < 3; index++)
Console.Write($"{index} ");
Console.WriteLine();
foreach (var word in new[] { "a", "b", "c" })
Console.Write($"{word} ");
Console.WriteLine();
var countdown = 3;
while (countdown > 0) { Console.Write($"{countdown} "); countdown--; }
Console.WriteLine();
do { Console.WriteLine("runs at least once"); } while (false); package main
import "core:fmt"
main :: proc() {
// One keyword covers every loop shape.
for index := 0; index < 3; index += 1 {
fmt.print(index, "")
}
fmt.println()
words := []string{"a", "b", "c"}
for word in words {
fmt.print(word, "")
}
fmt.println()
// for with one condition is a while loop.
countdown := 3
for countdown > 0 {
fmt.print(countdown, "")
countdown -= 1
}
fmt.println()
// Bare for is infinite; there is no do/while.
attempts := 0
for {
attempts += 1
fmt.println("runs at least once")
if attempts >= 1 { break }
}
} What Odin adds is labeled
break and continue, which let an inner loop exit an outer one by name. C# needs goto or a flag variable for the same job.using and finally become defer
defer schedules a statement to run when the enclosing scope exits, by any path — including an early return. It replaces both finally and using, and needs no interface on the type being cleaned up.using (var resource = new Resource("first"))
{
Console.WriteLine("working");
}
try
{
Console.WriteLine("in the try");
}
finally
{
Console.WriteLine("finally");
}
sealed class Resource : IDisposable
{
private readonly string name;
public Resource(string name) { this.name = name; Console.WriteLine($"open {name}"); }
public void Dispose() => Console.WriteLine($"close {name}");
} package main
import "core:fmt"
Resource :: struct {
name: string,
}
resource_open :: proc(name: string) -> Resource {
fmt.println("open", name)
return Resource{name = name}
}
resource_close :: proc(resource: Resource) {
fmt.println("close", resource.name)
}
main :: proc() {
{
resource := resource_open("first")
defer resource_close(resource)
fmt.println("working")
}
// Deferred statements run in reverse order at scope exit,
// so cleanup unwinds the setup that preceded it.
{
defer fmt.println("third")
defer fmt.println("second")
fmt.println("first")
}
} The advantage over
using is that it works on anything, not just an IDisposable, and the release sits on the line after the acquisition rather than being implied by a block. The disadvantage is symmetrical: nothing reminds you to write it, where the compiler at least warns about an unused disposable.Breaking out of nested loops
A loop can carry a label, and
break label or continue label targets that loop by name from any depth inside it.var grid = new[,] { { 1, 2 }, { 3, 4 } };
var target = 3;
for (var row = 0; row < 2; row++)
{
for (var column = 0; column < 2; column++)
{
if (grid[row, column] == target)
{
Console.WriteLine($"found at {row},{column}");
goto done; // goto is the only way out
}
}
}
done:
Console.WriteLine("searched"); package main
import "core:fmt"
main :: proc() {
grid := [2][2]int{{1, 2}, {3, 4}}
target := 3
search: for row in 0 ..< 2 {
for column in 0 ..< 2 {
if grid[row][column] == target {
fmt.printf("found at %v,%v\n", row, column)
break search
}
}
}
fmt.println("searched")
} C# has labels only for
goto, which is why this pattern reads as a code smell there and turns up as a found flag or an extracted method instead. Odin makes the structured version the obvious one.Methods & Procedures
Methods become procedures
A procedure is declared like every other constant:
name :: proc(parameters) -> results { }. Parameters sharing a type share one annotation, as left, right: int does, and declaration order in a package does not matter.Console.WriteLine(Greet("Ada"));
Console.WriteLine(Add(2, 3));
string Greet(string name) => $"Hello, {name}!";
int Add(int left, int right)
{
return left + right;
} package main
import "core:fmt"
greet :: proc(name: string) -> string {
return fmt.tprintf("Hello, %v!", name)
}
add :: proc(left, right: int) -> int {
return left + right
}
main :: proc() {
fmt.println(greet("Ada"))
fmt.println(add(2, 3))
} There is no method-versus-function distinction because there are no classes for a method to belong to. Everything lives at package scope, and the thing a procedure operates on is simply its first parameter — the
type_verb naming convention does the work a receiver does in C#.Optional and named arguments
Any parameter with a default can be passed by name using
name = value, in any order — the same syntax as a struct literal field, which is not a coincidence.Console.WriteLine(Connect("example.com"));
Console.WriteLine(Connect("example.com", port: 8080));
Console.WriteLine(Connect("example.com", secure: true, port: 443));
string Connect(string host, int port = 80, bool secure = false)
{
var scheme = secure ? "https" : "http";
return $"{scheme}://{host}:{port}";
} package main
import "core:fmt"
connect :: proc(host: string, port := 80, secure := false) -> string {
scheme := "https" if secure else "http"
return fmt.tprintf("%v://%v:%v", scheme, host, port)
}
main :: proc() {
fmt.println(connect("example.com"))
fmt.println(connect("example.com", port = 8080))
fmt.println(connect("example.com", secure = true, port = 443))
} This lands almost exactly on C#, down to the ordering freedom. The one difference worth knowing is that an Odin default must be a compile-time constant, so the
DateTime.Now-style default that C# also forbids stays forbidden, and there is no [CallerMemberName] family.out and ref
The
^ is both the pointer type (^int) and the dereference suffix (value^). Struct field access dereferences automatically, so the explicit ^ mostly appears on pointers to scalars like this one.if (TryDivide(10, 2, out var quotient))
Console.WriteLine(quotient);
var counter = 0;
Increment(ref counter);
Console.WriteLine(counter);
bool TryDivide(int numerator, int denominator, out int result)
{
if (denominator == 0) { result = 0; return false; }
result = numerator / denominator;
return true;
}
void Increment(ref int value) => value++; package main
import "core:fmt"
// out parameters are unnecessary: multiple return values are
// part of the type. Naming them documents the call site.
divide :: proc(numerator, denominator: int) -> (quotient: int, ok: bool) {
if denominator == 0 {
return 0, false
}
return numerator / denominator, true
}
// ref becomes an explicit pointer, visible on BOTH sides.
increment :: proc(value: ^int) {
value^ += 1
}
main :: proc() {
quotient, ok := divide(10, 2)
if ok {
fmt.println(quotient)
}
counter := 0
increment(&counter)
fmt.println(counter)
// Discarding a result requires the explicit blank _.
_, failed := divide(10, 0)
fmt.println(failed)
} C# marks a
ref at both the declaration and the call, which Odin matches with ^ and &. The genuine simplification is out: multiple return values remove the need for the concept, and each one keeps its own type instead of being an assignment obligation the compiler tracks.params arrays
A variadic parameter is written
..T and arrives as a slice; the same .. at a call site spreads an existing slice. fmt.println is itself variadic over ..any, which is why it accepts anything.Console.WriteLine(Total(1, 2, 3));
Console.WriteLine(Total(new[] { 4, 5, 6 }));
int Total(params int[] numbers)
{
var sum = 0;
foreach (var number in numbers) sum += number;
return sum;
} package main
import "core:fmt"
// ..T is a typed variadic; inside the procedure it is an
// ordinary slice, and no array is allocated to build it.
total :: proc(numbers: ..int) -> int {
sum := 0
for number in numbers {
sum += number
}
return sum
}
main :: proc() {
fmt.println(total(1, 2, 3))
// Spread an existing slice with ..
existing := []int{4, 5, 6}
fmt.println(total(..existing))
} The shape matches
params closely. What differs is the cost: C# allocates a real int[] on the heap for each call unless the JIT can prove otherwise, while the Odin slice points at a temporary the compiler laid out on the stack.Parameters are immutable
Parameters in Odin are immutable bindings — assigning to one is a compile error, so a parameter always holds the value the caller passed. Mutating the caller's data requires
^T and &value.Console.WriteLine(Normalize(" Ada "));
string Normalize(string name)
{
name = name.Trim(); // rebinding the parameter
return name.ToLower();
}
// C# has 'in' for readonly reference parameters, but a plain
// value parameter is a mutable local like any other. package main
import "core:fmt"
import "core:strings"
normalize :: proc(name: string) -> string {
// name = ... // Error: cannot assign to a parameter
trimmed := strings.trim_space(name)
return strings.to_lower(trimmed)
}
main :: proc() {
lowered := normalize(" Ada ")
defer delete(lowered)
fmt.println(lowered)
} C# treats a value parameter as an ordinary mutable local, which is why "reassign the parameter, then use it" is a common idiom and also why a debugger can show a value the caller never passed. Odin removes the ambiguity at the cost of one extra local.
Classes & Structs
Class becomes struct
There is no receiver and no
this: the thing a procedure works on is its first parameter. Odin auto-dereferences through pointers, so rectangle.width reads the same whether the variable is a Rectangle or a ^Rectangle.var rectangle = new Rectangle(3, 4);
Console.WriteLine(rectangle.Area);
rectangle.Scale(2);
Console.WriteLine(rectangle.Area);
Console.WriteLine(rectangle);
class Rectangle
{
public int Width { get; private set; }
public int Height { get; private set; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public int Area => Width * Height;
public void Scale(int factor)
{
Width *= factor;
Height *= factor;
}
public override string ToString() => $"{Width}x{Height}";
} package main
import "core:fmt"
// A struct is data. No methods, no properties, no header.
Rectangle :: struct {
width: int,
height: int,
}
// Read-only: take the struct by value.
rectangle_area :: proc(rectangle: Rectangle) -> int {
return rectangle.width * rectangle.height
}
// Mutating: take a pointer, and the caller writes &.
rectangle_scale :: proc(rectangle: ^Rectangle, factor: int) {
rectangle.width *= factor
rectangle.height *= factor
}
main :: proc() {
rectangle := Rectangle{3, 4}
fmt.println(rectangle_area(rectangle))
rectangle_scale(&rectangle, 2)
fmt.println(rectangle_area(rectangle))
// %v formats any type with no ToString from you.
fmt.println(rectangle)
fmt.println(size_of(Rectangle))
} The signature now carries what C# expresses through convention and modifiers:
^Rectangle can mutate, Rectangle cannot, and there is no way to hide either behind a property setter. size_of(Rectangle) is exactly the sum of its fields — no sync block, no type handle.No properties
Odin has no properties, no indexers, and no operator overloading — a field access reads memory and nothing else can be attached to it.
var temperature = new Temperature();
temperature.Celsius = 100;
Console.WriteLine(temperature.Fahrenheit);
Console.WriteLine(temperature.Celsius);
class Temperature
{
private double celsius;
public double Celsius
{
get => celsius;
set
{
if (value < -273.15) throw new ArgumentOutOfRangeException(nameof(value));
celsius = value;
}
}
public double Fahrenheit => celsius * 9 / 5 + 32;
} package main
import "core:fmt"
// A field is a field. Validation and derivation are ordinary
// procedures the caller invokes on purpose.
Temperature :: struct {
celsius: f64,
}
temperature_set_celsius :: proc(temperature: ^Temperature, value: f64) -> bool {
if value < -273.15 {
return false
}
temperature.celsius = value
return true
}
temperature_fahrenheit :: proc(temperature: Temperature) -> f64 {
return temperature.celsius * 9 / 5 + 32
}
main :: proc() {
temperature := Temperature{}
ok := temperature_set_celsius(&temperature, 100)
fmt.println(ok, temperature.celsius)
fmt.println(temperature_fahrenheit(temperature))
rejected := temperature_set_celsius(&temperature, -300)
fmt.println(rejected, temperature.celsius)
} The everyday consequence is that no assignment can run code. A C# property makes
temperature.Celsius = -300 throw from what looks like a store; in Odin the validation has a name and a return value, so a reader can see that it was called and what it answered.Constructors and Dispose
A constructor is an ordinary procedure returning the value, named
make_* or *_make by convention. There is no destructor, no finalizer, and no IDisposable — the defer at the call site is the whole mechanism.using (var buffer = new Buffer(4))
{
Console.WriteLine(buffer.Contents.Length);
}
sealed class Buffer : IDisposable
{
public int[] Contents { get; }
public Buffer(int capacity)
{
if (capacity <= 0) throw new ArgumentException("capacity must be positive");
Contents = new int[capacity];
Console.WriteLine("allocated");
}
public void Dispose() => Console.WriteLine("released");
} package main
import "core:fmt"
Buffer :: struct {
contents: []int,
}
// Nothing runs automatically on creation or destruction, so a
// type that owns memory gets a paired make and destroy.
buffer_make :: proc(capacity: int) -> (buffer: Buffer, ok: bool) {
if capacity <= 0 {
return Buffer{}, false
}
fmt.println("allocated")
return Buffer{contents = make([]int, capacity)}, true
}
buffer_destroy :: proc(buffer: ^Buffer) {
delete(buffer.contents)
fmt.println("released")
}
main :: proc() {
buffer, ok := buffer_make(4)
if !ok {
fmt.println("bad capacity")
return
}
defer buffer_destroy(&buffer)
fmt.println(len(buffer.contents))
} A C# constructor can refuse to produce an object by throwing. An Odin constructor cannot, so it returns the failure as a value — and a caller who ignores it gets a zeroed struct, which is at least a defined state rather than a half-built object.
Records and equality
Comparison, formatting, hashing, and copying are properties of every Odin struct rather than opt-ins — the compiler derives them from the field layout, which is also how
%v and map[Person]int work.var left = new Person("Ada", 36);
var right = new Person("Ada", 36);
Console.WriteLine(left == right); // value equality, free
Console.WriteLine(left); // formatted, free
Console.WriteLine(left with { Age = 37 });
Console.WriteLine(left.GetHashCode() == right.GetHashCode());
record Person(string Name, int Age); package main
import "core:fmt"
Person :: struct {
name: string,
age: int,
}
main :: proc() {
left := Person{"Ada", 36}
right := Person{"Ada", 36}
// Structural comparison and formatting come from the
// LAYOUT — every struct gets them, with no keyword.
fmt.println(left == right)
fmt.println(left)
fmt.printf("%#v\n", left)
// 'with' is a copy plus a field assignment.
older := left
older.age = 37
fmt.println(older)
} C# needed the
record keyword because a class defaults to reference equality and a hand-written Equals/GetHashCode pair is easy to get wrong. Odin has no such default to fix. What it does lack is with — a copy and an assignment, which is two lines instead of one.No inheritance
using on a struct field embeds that struct and promotes its fields into the outer type, so dog.name reaches dog.base.name. Nothing about it creates a subtype relationship.var dog = new Dog("Rex");
Console.WriteLine(dog.Describe());
Console.WriteLine(dog is Animal);
class Animal
{
protected string Name { get; }
protected Animal(string name) => Name = name;
public virtual string Speak() => "...";
public string Describe() => $"{Name} says {Speak()}";
}
sealed class Dog : Animal
{
public Dog(string name) : base(name) { }
public override string Speak() => "Woof";
} package main
import "core:fmt"
Animal :: struct {
name: string,
}
// 'using' on a field EMBEDS Animal and promotes its fields,
// so dog.name works. That is layout, not a type relationship:
// a Dog is never usable where an Animal is expected.
Dog :: struct {
using base: Animal,
breed: string,
}
dog_speak :: proc(dog: Dog) -> string {
return "Woof"
}
dog_describe :: proc(dog: Dog) -> string {
return fmt.tprintf("%v says %v", dog.name, dog_speak(dog))
}
main :: proc() {
dog := Dog{base = Animal{name = "Rex"}, breed = "corgi"}
fmt.println(dog.name) // promoted from Animal
fmt.println(dog.base.name) // and still reachable by path
fmt.println(dog_describe(dog))
} Nothing here dispatches. The whole point of
Describe calling a virtual Speak is that a subclass changes the answer, and Odin has no mechanism for it — there is no vtable, so a procedure taking a Dog takes a Dog. Runtime polymorphism must be built explicitly, which the next section does.Interfaces & Polymorphism
Interfaces become tagged unions
switch specific in shape both tests the active variant and binds it, so inside each arm specific has that concrete type. A union stores its tag alongside the value, and the union is exactly as large as its biggest variant plus that tag — no heap allocation, no vtable pointer.var shapes = new IShape[] { new Circle(2), new Square(3) };
foreach (var shape in shapes)
Console.WriteLine(shape.Area().ToString("F2"));
interface IShape { double Area(); }
record Circle(double Radius) : IShape
{
public double Area() => Math.PI * Radius * Radius;
}
record Square(double Side) : IShape
{
public double Area() => Side * Side;
} package main
import "core:fmt"
import "core:math"
Circle :: struct { radius: f64 }
Square :: struct { side: f64 }
// A union lists every variant up front. The value carries a
// tag, and the compiler checks that the switch handles it.
Shape :: union {
Circle,
Square,
}
shape_area :: proc(shape: Shape) -> f64 {
switch specific in shape {
case Circle: return math.PI * specific.radius * specific.radius
case Square: return specific.side * specific.side
}
return 0
}
main :: proc() {
circle := Circle{2}
square := Square{3}
shapes := []Shape{circle, square}
for shape in shapes {
fmt.printf("%.2f\n", shape_area(shape))
}
} This inverts the trade an interface makes. Adding a shape in C# costs nothing and touches nothing; adding one here makes the compiler point at every
switch that now has a hole. Adding an operation is the reverse: a new interface method breaks every implementer, while a new procedure over the union breaks nothing.Open polymorphism by hand
A
rawptr is an untyped pointer and cast(^Logger)data converts it back to the concrete type. Nothing verifies the pairing — you wrote both halves of the struct literal, so the correctness is yours.// An interface stays open: any assembly, including one you
// do not control, can implement it later.
var handlers = new IHandler[] { new LogHandler(), new EchoHandler() };
foreach (var handler in handlers)
Console.WriteLine(handler.Handle("ping"));
interface IHandler { string Handle(string message); }
sealed class LogHandler : IHandler
{
public string Handle(string message) => $"logged: {message}";
}
sealed class EchoHandler : IHandler
{
public string Handle(string message) => $"echo: {message}";
} package main
import "core:fmt"
// When the implementation set must stay open, you build the
// vtable yourself: a data pointer plus its procedures.
Handler :: struct {
data: rawptr,
handle: proc(data: rawptr, message: string) -> string,
}
Logger :: struct { prefix: string }
Echoer :: struct { }
logger_handle :: proc(data: rawptr, message: string) -> string {
logger := cast(^Logger)data
return fmt.tprintf("%v: %v", logger.prefix, message)
}
echoer_handle :: proc(data: rawptr, message: string) -> string {
return fmt.tprintf("echo: %v", message)
}
main :: proc() {
logger := Logger{prefix = "logged"}
echoer := Echoer{}
handlers := []Handler{
{&logger, logger_handle},
{&echoer, echoer_handle},
}
for handler in handlers {
fmt.println(handler.handle(handler.data, "ping"))
}
} This is what the CLR builds for an interface call anyway: a target reference plus a method slot. Writing it out costs the type checking and the tooling, which is exactly why the tagged union above is the better default and this is reserved for genuinely open plugin boundaries.
object becomes any
any is a rawptr plus a typeid. Because it points at the original value instead of copying it onto the heap, an any must never outlive what it refers to.object[] values = { 42, "text", 3.14 };
foreach (var value in values)
Console.WriteLine($"{value} is {value.GetType().Name}");
object boxed = 42;
Console.WriteLine(boxed is int);
Console.WriteLine((int)boxed + 1);
// Boxing a value type allocates and copies onto the heap.
Console.WriteLine(boxed.GetHashCode() == 42.GetHashCode()); package main
import "core:fmt"
main :: proc() {
number := 42
text := "text"
ratio := 3.14
// 'any' is a pointer plus a typeid. It POINTS AT the
// value — no boxing, no allocation, and no copy.
values := []any{number, text, ratio}
for value in values {
fmt.printf("%v is %v\n", value, value.id)
}
boxed: any = number
fmt.println(boxed.id == int)
switch specific in boxed {
case int: fmt.println("an int:", specific + 1)
case string: fmt.println("a string:", specific)
}
} That non-copying design is the whole difference from boxing. C# heap-allocates when an
int becomes an object, which is why Span<T>, generics, and ArrayPool exist to avoid it. Odin sidesteps the allocation and hands you a lifetime rule instead, which is why any belongs in printing and debugging code rather than in stored data.No extension methods
Because nothing is a member of anything, there is no distinction between a type's own operations and ones added later — every procedure is a free procedure taking the value.
Console.WriteLine("hello".Shout());
Console.WriteLine(42.Double());
// Extension methods make someone else's type look like it
// has your method — the whole of LINQ is built this way.
static class Extensions
{
public static string Shout(this string text) => text.ToUpper() + "!";
public static int Double(this int value) => value * 2;
} package main
import "core:fmt"
import "core:strings"
// A procedure is never attached to a type, so "extending"
// one is just writing a procedure that takes it.
shout :: proc(text: string) -> string {
upper := strings.to_upper(text)
defer delete(upper)
return fmt.tprintf("%v!", upper)
}
double :: proc(value: int) -> int {
return value * 2
}
main :: proc() {
fmt.println(shout("hello"))
fmt.println(double(42))
} The loss is the fluent chain, and it is the same loss as LINQ's:
text.Trim().ToLower().Shout() becomes nested calls or intermediate variables. What you gain is that a call to shout is findable by grepping for shout ::, with exactly one result and no using directive deciding which one applies.Exceptions & Error Values
try/catch becomes a return value
An error enum's zero value is the success case, so
error != nil reads as "something went wrong" for enums, unions, and pointers alike. Nothing unwinds — an error travels back one return at a time.try
{
Console.WriteLine(Withdraw(100, 50));
Console.WriteLine(Withdraw(100, 500));
}
catch (InsufficientFundsException error)
{
Console.WriteLine($"caught: {error.Message}");
}
int Withdraw(int balance, int amount)
{
if (amount > balance)
throw new InsufficientFundsException($"need {amount}, have {balance}");
return balance - amount;
}
sealed class InsufficientFundsException : Exception
{
public InsufficientFundsException(string message) : base(message) { }
} package main
import "core:fmt"
// There are no exceptions. An error is a value, and the
// idiomatic shape is an enum whose zero value means "fine".
Account_Error :: enum {
None,
Insufficient_Funds,
Account_Frozen,
}
withdraw :: proc(balance, amount: int) -> (remaining: int, error: Account_Error) {
if amount > balance {
return balance, .Insufficient_Funds
}
return balance - amount, .None
}
main :: proc() {
remaining, error := withdraw(100, 50)
fmt.println(remaining, error)
remaining, error = withdraw(100, 500)
if error != nil {
fmt.println("failed:", error)
return
}
fmt.println(remaining)
} The cost is that every frame between the failure and the handler must declare and forward the error, where a C# exception flies past twenty of them untouched. The benefit is that a signature tells you exactly what can go wrong, which no C# signature has done since checked exceptions were rejected.
or_return and or_else
or_return is a suffix that collapses if error != nil { return ..., error } into nothing: it inspects the last return value and, if non-zero, returns it from the enclosing procedure. It only compiles when that procedure's results are named.try
{
Console.WriteLine(BuildUrl("example.com", "8080"));
Console.WriteLine(BuildUrl("example.com", "eighty"));
}
catch (FormatException)
{
Console.WriteLine("the exception propagated on its own");
}
// The ?? fallback is the closest thing to or_else:
var port = int.TryParse("nope", out var parsed) ? parsed : 80;
Console.WriteLine(port);
string BuildUrl(string host, string portText)
{
var port = int.Parse(portText); // no forwarding code needed
return $"http://{host}:{port}";
} package main
import "core:fmt"
import "core:strconv"
Parse_Error :: enum { None, Not_A_Number }
parse_port :: proc(text: string) -> (port: int, error: Parse_Error) {
value, ok := strconv.parse_int(text)
if !ok {
return 0, .Not_A_Number
}
return value, .None
}
// or_return returns early on any non-zero error. It requires
// NAMED return values on the enclosing procedure.
build_url :: proc(host, port_text: string) -> (url: string, error: Parse_Error) {
port := parse_port(port_text) or_return
return fmt.tprintf("http://%v:%v", host, port), .None
}
main :: proc() {
good, _ := build_url("example.com", "8080")
fmt.println(good)
_, error := build_url("example.com", "eighty")
fmt.println("failed:", error)
// or_else supplies a fallback inline.
port := strconv.parse_int("nope") or_else 80
fmt.println(port)
} This gets error-value code close to the density of exceptions for the common "pass it up" case. What it deliberately will not do is skip frames — every procedure on the path still declares the error, so the route from failure to handler is written down rather than inferred from a stack trace.
finally becomes defer
A
defer runs on every exit from its scope, including an early return in an error branch — which is what makes it a complete replacement for finally even though nothing unwinds a stack.Console.WriteLine(DoWork(false));
Console.WriteLine(DoWork(true));
string DoWork(bool shouldFail)
{
Console.WriteLine("acquire");
try
{
if (shouldFail) return "failed";
Console.WriteLine("using it");
return "ok";
}
finally
{
Console.WriteLine("release");
}
} package main
import "core:fmt"
Work_Error :: enum { None, Failed }
do_work :: proc(should_fail: bool) -> (error: Work_Error) {
fmt.println("acquire")
defer fmt.println("release")
if should_fail {
return .Failed // the defer still runs
}
fmt.println("using it")
return .None
}
main :: proc() {
fmt.println(do_work(false))
fmt.println(do_work(true))
} The placement is the real improvement. A
finally block sits at the bottom, far from the acquisition it balances, and a long try can hide whether they still match. A defer sits on the next line, so a reviewer sees both at once.Assertions and panics
assert and panic abort the process — there is no recover, so a panic is genuinely the end. That is why they are reserved for conditions meaning the program is wrong, not for failures a caller could handle.Console.WriteLine(Average(new[] { 1.0, 2.0, 3.0 }));
try
{
Average(Array.Empty<double>());
}
catch (ArgumentException)
{
// A programmer error and an expected failure are the
// same mechanism, so both are equally catchable.
Console.WriteLine("even a bug is catchable");
}
double Average(double[] numbers)
{
if (numbers.Length == 0) throw new ArgumentException("empty");
return numbers.Sum() / numbers.Length;
} package main
import "core:fmt"
average :: proc(numbers: []f64) -> f64 {
// assert is for conditions that should be IMPOSSIBLE if
// the code is correct. It panics, and there is no recover.
assert(len(numbers) > 0, "average of an empty slice")
sum := 0.0
for number in numbers {
sum += number
}
return sum / f64(len(numbers))
}
main :: proc() {
values := []f64{1, 2, 3}
fmt.println(average(values))
fmt.println("assertions are compiled out under -o:speed")
} C# routes both categories through
Exception, which is how a catch (Exception) at a request boundary ends up swallowing a null-dereference bug and logging it as a failed request. Odin enforces the split: expected failures are return values, impossible states abort.Memory & Allocators
There is no garbage collector
Odin has no garbage collector, no reference counting, and no finalizers. Memory is released exactly when a
delete, free, or arena teardown runs, and the defer beside the allocation is the convention that keeps the pair visible.var records = new List<Record>();
for (var index = 0; index < 1000; index++)
records.Add(new Record(index, $"row {index}"));
Console.WriteLine(records.Count);
Console.WriteLine($"gen0 collections so far: {GC.CollectionCount(0)}");
records = null!;
GC.Collect();
Console.WriteLine("the collector handled it, whenever it chose to");
record Record(int Id, string Name); package main
import "core:fmt"
Record :: struct {
id: int,
name: string,
}
main :: proc() {
// You asked for this memory, so you release it.
records := make([]Record, 1000)
defer delete(records)
for index in 0 ..< len(records) {
records[index] = Record{id = index}
}
fmt.println(len(records))
// Nothing runs in the background. Nothing pauses. The
// only deallocation is the one you wrote.
fmt.println("released at the end of this scope")
} For a .NET reader this is the familiar argument in its strongest form. Every trick the runtime offers to dodge the collector —
Span, stackalloc, ArrayPool, struct over class, server GC tuning, TryStartNoGCRegion — exists because allocation is cheap and collection is not. Odin removes the collector rather than the allocation, which is the same bargain Unity made when it built Burst and DOTS.new and free
new(T) allocates one zeroed T on the heap and returns a ^T; free hands it back. Field access dereferences automatically, so first.next.value needs no explicit ^.var first = new Node(1);
first.Next = new Node(2);
Console.WriteLine(first.Value);
Console.WriteLine(first.Next!.Value);
Console.WriteLine(first.Next.Next is null);
// Nothing to release. When nothing references them,
// they go away at some point the runtime chooses.
sealed class Node
{
public int Value;
public Node? Next;
public Node(int value) => Value = value;
} package main
import "core:fmt"
Node :: struct {
value: int,
next: ^Node,
}
main :: proc() {
first := new(Node)
defer free(first)
first.value = 1
second := new(Node)
defer free(second)
second.value = 2
first.next = second
fmt.println(first.value)
fmt.println(first.next.value)
fmt.println(first.next.next == nil)
} A linked structure is where the missing collector bites hardest, because ownership stops being obvious — two nodes pointing at each other have no natural owner, and neither
defer free knows about the other. The practical answer is the arena two rows down: allocate the whole graph in one region and drop the region.Containers must be released
The rule that makes this tractable: whoever asked for the allocation owns it.
make, append, and any procedure returning a freshly built string or slice all allocate, and each wants a matching delete.var words = new List<string> { "alpha", "beta" };
var lookup = new Dictionary<string, int> { ["alpha"] = 1 };
var joined = string.Join(", ", words);
Console.WriteLine(joined);
Console.WriteLine(lookup.Count);
// Four heap allocations here (list, its backing array, the
// dictionary, the joined string) and zero cleanup code. package main
import "core:fmt"
import "core:strings"
main :: proc() {
// Each of these owns a heap buffer.
words: [dynamic]string
defer delete(words)
append(&words, "alpha", "beta")
lookup := make(map[string]int)
defer delete(lookup)
lookup["alpha"] = 1
// So does the result of join.
joined := strings.join(words[:], ", ")
defer delete(joined)
fmt.println(joined)
fmt.println(len(lookup))
} Reading Odin's standard library becomes a habit of asking "does this return something new, or a view into what I passed?" —
strings.join builds and must be deleted, strings.has_prefix only looks. Getting it wrong leaks rather than crashing, which is why the tracking allocator below exists.Arena allocators
The implicit
context carries the current allocator, and assigning context.allocator redirects every allocation below that point — inside procedures you call and inside the standard library alike. No signature changes.// .NET has one heap and one strategy. ArrayPool and
// ObjectPool let you reuse buffers, but you cannot say
// "put these thousand objects together and drop them at once".
var pool = System.Buffers.ArrayPool<int>.Shared;
var buffer = pool.Rent(1024);
try
{
buffer[0] = 7;
Console.WriteLine(buffer[0]);
}
finally
{
pool.Return(buffer);
}
Console.WriteLine("renting is the closest .NET gets"); package main
import "core:fmt"
import "core:mem/virtual"
main :: proc() {
// An arena hands out memory by bumping a pointer and
// frees EVERYTHING in one call — no per-object bookkeeping.
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
defer virtual.arena_destroy(&arena)
{
// Redirect every allocation in this scope, including
// ones inside procedures we call.
context.allocator = virtual.arena_allocator(&arena)
rows: [dynamic]string
for index in 0 ..< 100 {
append(&rows, fmt.aprintf("row %v", index))
}
fmt.println(rows[0], rows[1])
// No delete anywhere. The arena owns all of it.
}
fmt.println("one teardown released a hundred allocations")
} This is the feature that makes life without a collector practical, and .NET has no counterpart. Lifetimes that are genuinely bulk — everything a request touched, everything a frame drew — stop needing per-object ownership: allocate into the arena and reset it when the phase ends.
The temporary allocator
Every allocating procedure in
core: comes in three flavors distinguished by prefix: t for the temporary arena, a for the caller-owned heap, and no prefix for writing into a buffer you supply. The prefix is how you choose the lifetime.for (var index = 0; index < 3; index++)
Console.WriteLine(LabelFor(index));
// Short-lived strings are the most common allocation in a
// .NET program and the least thought about — every one of
// these is a gen0 object waiting for a collection.
string LabelFor(int index) => $"item {index}"; package main
import "core:fmt"
label_for :: proc(index: int) -> string {
// tprintf allocates in the TEMPORARY allocator: an arena
// for values that die before the next frame or request
// boundary. No delete, no ownership question.
return fmt.tprintf("item %v", index)
}
main :: proc() {
for index in 0 ..< 3 {
fmt.println(label_for(index))
}
// Release the whole temporary arena at a point you choose.
free_all(context.temp_allocator)
fmt.println("temporary storage reset")
} This is the closest Odin comes to "just make a string and forget it", and the difference is where the sweep happens: you call
free_all at a boundary you picked, rather than a collector choosing a moment you did not. For a frame loop that distinction is the whole ballgame.Finding leaks
A tracking allocator wraps another allocator and records every outstanding allocation with the source location that requested it —
#caller_location is threaded through the allocator interface, so the file and line are the ones in your code.var before = GC.GetTotalAllocatedBytes();
var rows = new List<string>();
for (var index = 0; index < 1000; index++) rows.Add($"row {index}");
var after = GC.GetTotalAllocatedBytes();
Console.WriteLine(after > before);
Console.WriteLine(rows.Count);
// A "leak" in .NET means something still holds a reference,
// so finding one means asking who still points at it —
// a dump, a profiler, and a retention graph. package main
import "core:fmt"
import "core:mem"
main :: proc() {
tracker: mem.Tracking_Allocator
mem.tracking_allocator_init(&tracker, context.allocator)
defer mem.tracking_allocator_destroy(&tracker)
{
context.allocator = mem.tracking_allocator(&tracker)
released := make([]int, 10)
delete(released)
forgotten := make([]int, 10)
_ = forgotten // deliberately never deleted
fmt.println("outstanding allocations:", len(tracker.allocation_map))
for _, entry in tracker.allocation_map {
fmt.printf(" %v bytes from %v:%v\n",
entry.size, entry.location.file_path, entry.location.line)
}
}
} A leak means opposite things in the two runtimes. In .NET it means something still holds a reference and the collector is right to keep the object, so finding it means capturing a dump and walking a retention graph. Here it means a
delete was never written, and this tool names the line that allocated it.Generics & Compile Time
Generics
A
$ marks a parameter the compiler must resolve when the call is compiled. $T in a value position infers the type from the argument; $T: typeid in a type declaration makes the type itself parametric.Console.WriteLine(Largest(new[] { 3, 9, 1 }));
Console.WriteLine(Largest(new[] { "pear", "apple", "quince" }));
var stack = new Stack<int>();
stack.Push(1);
stack.Push(2);
Console.WriteLine(stack.Pop());
T Largest<T>(T[] items) where T : IComparable<T>
{
var best = items[0];
foreach (var item in items)
if (item.CompareTo(best) > 0) best = item;
return best;
} package main
import "core:fmt"
// $T is a type parameter resolved at compile time. One
// specialized copy is generated per type actually used —
// and there are no constraints to satisfy.
largest :: proc(items: []$T) -> T {
best := items[0]
for item in items[1:] {
if item > best {
best = item
}
}
return best
}
// A type can be parametric too.
Stack :: struct($T: typeid) {
items: [dynamic]T,
}
stack_push :: proc(stack: ^Stack($T), item: T) {
append(&stack.items, item)
}
stack_pop :: proc(stack: ^Stack($T)) -> T {
return pop(&stack.items)
}
main :: proc() {
numbers := []int{3, 9, 1}
words := []string{"pear", "apple", "quince"}
fmt.println(largest(numbers))
fmt.println(largest(words))
stack: Stack(int)
defer delete(stack.items)
stack_push(&stack, 1)
stack_push(&stack, 2)
fmt.println(stack_pop(&stack))
} The mechanism is closer to C++ templates than to .NET generics: every instantiation is compiled separately, so
> works if the concrete type supports it and no IComparable constraint is needed. What you lose is the shared runtime representation — reflection over an open generic type, and List<T> for reference types sharing one compiled body.Compile-time value parameters
A
$ parameter can bind a value, not only a type: [$N]int matches any fixed array of int and binds its length as a compile-time constant inside the procedure.// C# generics take types, never values. A fixed-size buffer
// length cannot be a type argument, so this is a runtime
// check the JIT may or may not remove.
Console.WriteLine(SumOf(new[] { 1, 2, 3 }));
Console.WriteLine(SumOf(new[] { 1, 2, 3, 4 }));
int SumOf(int[] values)
{
var total = 0;
foreach (var value in values) total += value;
return total;
} package main
import "core:fmt"
// $N matches on the ARRAY LENGTH, which is part of the type.
// The loop bound is a compile-time constant in each copy.
sum_of :: proc(values: [$N]int) -> int {
total := 0
for index in 0 ..< N {
total += values[index]
}
return total
}
main :: proc() {
three := [3]int{1, 2, 3}
four := [4]int{1, 2, 3, 4}
fmt.println(sum_of(three))
fmt.println(sum_of(four))
// The length travels with the type, so this is checked:
// sum_of([2]int{1, 2}) // a third specialization
fmt.println(len(three), len(four))
} C# generics are constrained to type arguments, which is why fixed-capacity buffers, matrix dimensions, and unit-of-measure tricks all end up as runtime values or code generation. This is the same capability C++ non-type template parameters and Rust const generics provide, and it is genuinely absent from .NET.
Conditional compilation
when is the compile-time sibling of if: its condition must be a constant expression, and the branch not taken is discarded before type checking — which is what makes it safe to reference platform-specific procedures inside one.#if DEBUG
Console.WriteLine("debug build");
#else
Console.WriteLine("release build");
#endif
Console.WriteLine(Environment.Is64BitProcess);
Console.WriteLine(IntPtr.Size * 8);
// #if is a preprocessor directive: it works on text, before
// the compiler sees the code, with no type checking. package main
import "core:fmt"
main :: proc() {
// 'when' is part of the LANGUAGE, evaluated by the
// compiler. The untaken branch is never type-checked
// and never emitted.
when ODIN_DEBUG {
fmt.println("debug build")
} else {
fmt.println("release build")
}
when ODIN_OS == .Darwin {
fmt.println("compiled for macOS")
} else when ODIN_OS == .Linux {
fmt.println("compiled for Linux")
} else {
fmt.println("compiled for something else")
}
fmt.println(ODIN_ARCH, size_of(rawptr) * 8, "bit")
} Odin has no preprocessor at all, and
when is why it does not need one. Unlike #if, it has real scoping and real type checking on the surviving branch, and it composes with $ parameters so a generic procedure can specialize its body per type.Reflection and attributes
Odin keeps full runtime type information —
core:reflect reads field names, types, offsets, and struct tags — because that is what fmt's %v and any are built on.var person = new Person("Ada", 36);
var type = person.GetType();
Console.WriteLine(type.Name);
foreach (var property in type.GetProperties())
Console.WriteLine($"{property.Name}: {property.PropertyType.Name}");
// Reflection can also ACT: read attributes, invoke methods,
// construct types, and emit IL at runtime.
Console.WriteLine(type.GetProperty("Name")!.GetValue(person));
record Person(string Name, int Age); package main
import "core:fmt"
import "core:reflect"
Person :: struct {
name: string,
age: int,
}
main :: proc() {
person := Person{"Ada", 36}
// The compiler emits type information, so you can LOOK at
// a type's shape at runtime.
fmt.println(typeid_of(Person))
fmt.println(reflect.struct_field_names(Person))
fmt.println(reflect.struct_field_types(Person))
name_field := reflect.struct_field_value_by_name(person, "name")
fmt.println(name_field)
fmt.println(size_of(Person), align_of(Person))
} The line is between inspecting and acting. You can ask what fields a struct has; you cannot invoke something discovered by name, construct a type from a string, or emit code, because there is no method table and no JIT. Every .NET library built on those — the serializers, the DI containers, the ORMs, the mocking frameworks — has no Odin analog.
Namespaces & Visibility
Namespaces become directories
Odin's compilation unit is the directory, so splitting a package across files requires no declaration in either one — they simply share a namespace. Every import is qualified at the point of use; there is no way to pull names in unqualified.
// A namespace is declared in the file and is independent of
// where the file lives:
// namespace Company.Product.Geometry;
// using Company.Product.Geometry;
// using Json = System.Text.Json;
//
// 'using' pulls names into scope unqualified, so two
// namespaces can collide and need an alias to disambiguate.
using Json = System.Text.Json;
Console.WriteLine(Json.JsonSerializer.Serialize(new[] { 1, 2, 3 })); // A DIRECTORY is a package. Every .odin file in it shares one
// namespace, declaration order is free, and there are no
// headers, prototypes, or include guards.
//
// geometry/vector.odin package geometry
// geometry/matrix.odin package geometry
// main.odin import "geometry"
package main
import "core:fmt"
import "core:encoding/json"
// The import name is the last path segment; alias it when two
// packages would collide.
import string_helpers "core:strings"
main :: proc() {
encoded, _ := json.marshal([]int{1, 2, 3})
defer delete(encoded)
fmt.println(string(encoded))
fmt.println(string_helpers.to_upper("aliased"))
} C# decouples namespace from folder, which is flexible and also why a large solution needs conventions and analyzers to keep the two aligned. Odin ties them together and makes qualification mandatory rather than conventional, so
json.marshal and string_helpers.to_upper can never shadow each other.Access modifiers
Visibility is an attribute written above the declaration and enforced by the package system at compile time — not a runtime check on a member table.
var cache = new Cache();
Console.WriteLine(cache.Fetch("KEY"));
// C# has six accessibility levels: public, private,
// protected, internal, protected internal, private protected.
// InternalsVisibleTo can open 'internal' to another assembly,
// and reflection reaches private members anyway.
sealed class Cache
{
public string Fetch(string key) => Normalize(key);
private string Normalize(string key) => key.ToLower();
} package main
import "core:fmt"
import "core:strings"
// Two levels, both compile-time facts:
// @(private) hidden from other packages
// @(private = "file") hidden from other files here too
@(private = "file")
normalize :: proc(key: string) -> string {
return strings.to_lower(key)
}
fetch :: proc(key: string) -> string {
return normalize(key)
}
main :: proc() {
lowered := fetch("KEY")
defer delete(lowered)
fmt.println(lowered)
// From another package, normalize does not exist. There
// is no reflection that reaches it and no attribute that
// opens it up.
} C#'s six levels exist because inheritance needs
protected and assemblies need internal; Odin has neither concept, so two levels cover it. The other difference is that private really is unreachable — no reflection, no InternalsVisibleTo, no dynamic proxy reaching in.No partial classes, no source generators
Because a package is a directory and procedures are never members of a type, splitting code across files needs no
partial — any file in the package can add procedures that operate on any type declared there.// A partial class lets a generator own half a type:
// public partial class Model { } your file
// public partial class Model { ... } generated file
// Source generators, [JsonSerializable], regex generators,
// and the whole AOT-friendly toolchain rely on this.
var model = new Model { Name = "generated half is real code" };
Console.WriteLine(model.Describe());
partial class Model { public string Name = ""; }
partial class Model { public string Describe() => Name; } // There are no partial types and no source generators,
// because a package already spans as many files as you like —
// declarations in the same directory see each other with no
// keyword at all.
//
// model/fields.odin package model
// model/behavior.odin package model
package main
import "core:fmt"
Model :: struct {
name: string,
}
model_describe :: proc(model: Model) -> string {
return model.name
}
main :: proc() {
model := Model{name = "one package, as many files as you like"}
fmt.println(model_describe(model))
} What genuinely has no counterpart is the source generator. .NET's move toward AOT replaced runtime reflection with compile-time generation, and Odin has neither — the equivalent work is done by
$ parameters and when, or by a script you run yourself before building.Enums, Unions & Optionals
Enums
Inside a context where the enum type is known, Odin lets you write just
.Info — the implicit selector. [Level]string is an array indexed by the enum, so the compiler demands an entry for every member and no index can be out of range.var level = Level.Info;
Console.WriteLine($"{level} {(int)level}");
foreach (Level member in Enum.GetValues<Level>())
Console.Write($"{member}={(int)member} ");
Console.WriteLine();
// An enum is an integer wearing a name — any integer.
var invented = (Level)99;
Console.WriteLine(invented);
Console.WriteLine(Enum.IsDefined(invented));
enum Level { Debug, Info, Error } package main
import "core:fmt"
Level :: enum {
Debug,
Info,
Error,
}
// Values can be assigned and the backing type chosen:
Status_Code :: enum u16 {
Ok = 200,
Not_Found = 404,
}
main :: proc() {
level := Level.Info
fmt.println(level, int(level))
for member in Level {
fmt.printf("%v=%v ", member, int(member))
}
fmt.println()
fmt.println(Status_Code.Not_Found, u16(Status_Code.Not_Found))
// An enum-indexed array cannot be indexed out of range,
// and the compiler requires an entry for every member.
labels := [Level]string{
.Debug = "verbose",
.Info = "normal",
.Error = "loud",
}
fmt.println(labels[level])
} A C# enum is an integer with names attached, which is why
(Level)99 is legal and Enum.IsDefined exists. An Odin enum is a distinct type, and the enum-indexed array is the piece with no C# analog at all — a lookup table the compiler proves is total.Tagged unions
A union's zero value is
nil, meaning no variant is held — which is why the switch falls through to the final return for an untouched Value. The union occupies its largest variant plus a tag, with no heap allocation.Console.WriteLine(Describe(42));
Console.WriteLine(Describe("text"));
Console.WriteLine(Describe(3.14));
// Modeling "one of several" in C# means an abstract base
// plus sealed subclasses, or object plus a type switch —
// and either way the compiler cannot check exhaustiveness.
string Describe(object value) => value switch
{
int number => $"an integer: {number}",
string text => $"a string: {text}",
double ratio => $"a float: {ratio}",
_ => "something else",
}; package main
import "core:fmt"
// A union lists its variants. The value carries a tag, and
// reading the wrong variant is impossible.
Value :: union {
int,
string,
f64,
}
describe :: proc(value: Value) -> string {
switch specific in value {
case int: return fmt.tprintf("an integer: %v", specific)
case string: return fmt.tprintf("a string: %v", specific)
case f64: return fmt.tprintf("a float: %v", specific)
}
return "nothing"
}
main :: proc() {
fmt.println(describe(42))
fmt.println(describe("text"))
fmt.println(describe(3.14))
// A union's zero value is nil — no variant set.
empty: Value
fmt.println(describe(empty))
fmt.println(empty == nil)
} This is the discriminated union C# has been asking for through five language versions. The
object version above boxes every value type, needs a _ arm the compiler cannot prove is unreachable, and gives no warning when a fourth case appears — the union fixes all three.Nullable becomes Maybe
Maybe(T) is a union of T and nil, and .? unwraps it into a value plus a bool. Because absence lives in the type, a plain User is guaranteed to be a user.var found = FindUser(1);
if (found.HasValue)
Console.WriteLine(found.Value.Name);
var missing = FindUser(99);
Console.WriteLine(missing.HasValue);
Console.WriteLine(missing?.Name ?? "anonymous");
// Nullable<T> works for value types; for reference types the
// ? is an erased annotation the runtime never enforces.
User? FindUser(int id) => id == 1 ? new User("Ada") : null;
readonly record struct User(string Name); package main
import "core:fmt"
User :: struct {
name: string,
}
// Maybe(T) is a union of T and nil, so absence is part of
// the TYPE — and it works identically for every type.
find_user :: proc(id: int) -> Maybe(User) {
if id == 1 {
return User{name = "Ada"}
}
return nil
}
main :: proc() {
// .? unwraps and reports whether there was anything.
if user, ok := find_user(1).?; ok {
fmt.println(user.name)
}
missing := find_user(99)
fmt.println(missing == nil)
// or_else supplies a default inline.
fallback := find_user(99).? or_else User{name = "anonymous"}
fmt.println(fallback.name)
} C# has two unrelated mechanisms here:
Nullable<T> is a real struct the runtime enforces, while string? is an annotation erased at compile time that produces warnings and nothing more. Maybe(T) is one mechanism, enforced, for every type — closer to F#'s option than to anything in C#.Unions as error results
A union may mix a payload type and an error type, which gives the
Result shape directly — no wrapper struct, no base record, and no allocation beyond the union itself.var result = Divide(10, 0);
Console.WriteLine(result switch
{
Success success => $"got {success.Value}",
Failure failure => $"failed: {failure.Reason}",
_ => "impossible",
});
// A Result type in C# is a class hierarchy plus a switch the
// compiler cannot prove is exhaustive.
Outcome Divide(int numerator, int denominator) =>
denominator == 0 ? new Failure("division by zero") : new Success(numerator / denominator);
abstract record Outcome;
sealed record Success(int Value) : Outcome;
sealed record Failure(string Reason) : Outcome; package main
import "core:fmt"
Division_Error :: enum {
Division_By_Zero,
}
// A union of the value and the error is the other common
// shape, alongside the (value, error) pair.
Outcome :: union {
int,
Division_Error,
}
divide :: proc(numerator, denominator: int) -> Outcome {
if denominator == 0 {
return Division_Error.Division_By_Zero
}
return numerator / denominator
}
main :: proc() {
switch specific in divide(10, 0) {
case int: fmt.println("got", specific)
case Division_Error: fmt.println("failed:", specific)
}
switch specific in divide(10, 2) {
case int: fmt.println("got", specific)
case Division_Error: fmt.println("failed:", specific)
}
} Both shapes are idiomatic in Odin: the
(value, error) pair from the errors section reads better when the caller usually wants the value, and this union reads better when the two outcomes are genuinely equal alternatives. The C# equivalent needs a record hierarchy and still cannot be checked for exhaustiveness.Concurrency & Data Layout
There is no async/await
A thread procedure takes a single
rawptr, which is how state reaches it and how a result comes back — there is no closure to capture and no Task<T> to carry a return value.var results = await Task.WhenAll(FetchAsync(1), FetchAsync(2));
Console.WriteLine(string.Join(",", results));
// async/await is a compiler transform: each method becomes a
// state machine, and the Task carries continuation, result,
// and exception. It is the backbone of every modern .NET API.
async Task<int> FetchAsync(int id)
{
await Task.Delay(1);
return id * 10;
} package main
import "core:fmt"
import "core:thread"
import "core:sync"
// There is no async, no await, no Task, and no scheduler in
// the binary. Concurrency is OS threads and explicit waiting.
Job :: struct {
id: int,
result: int,
}
run_job :: proc(argument: rawptr) {
job := cast(^Job)argument
job.result = job.id * 10
}
main :: proc() {
jobs := [2]Job{{id = 1}, {id = 2}}
handles: [2]^thread.Thread
for index in 0 ..< len(jobs) {
handles[index] = thread.create_and_start_with_data(&jobs[index], run_job)
}
for handle in handles {
thread.join(handle)
thread.destroy(handle)
}
fmt.println(jobs[0].result, jobs[1].result)
_ = sync.Mutex{}
} This is a real gap and worth being clear about.
async/await makes ten thousand concurrent I/O operations cheap on a handful of threads, and Odin has no equivalent — a thread per operation is the model. For the CPU-bound, frame-oriented work Odin targets that is the right shape; for a web service handling many idle connections it is not.lock becomes an explicit mutex
A
sync.Mutex is a field on the data it protects rather than a separate lock object, and defer sync.mutex_unlock releases it on every exit from the scope — the same guarantee lock gives through its block.var total = 0;
var gate = new object();
var workers = new System.Threading.Thread[4];
for (var index = 0; index < workers.Length; index++)
{
workers[index] = new System.Threading.Thread(() =>
{
lock (gate) { total += 10; }
});
workers[index].Start();
}
foreach (var worker in workers) worker.Join();
Console.WriteLine(total); package main
import "core:fmt"
import "core:thread"
import "core:sync"
Shared :: struct {
mutex: sync.Mutex,
total: int,
}
worker :: proc(argument: rawptr) {
shared := cast(^Shared)argument
sync.mutex_lock(&shared.mutex)
defer sync.mutex_unlock(&shared.mutex)
shared.total += 10
}
main :: proc() {
shared := Shared{}
workers: [4]^thread.Thread
for index in 0 ..< len(workers) {
workers[index] = thread.create_and_start_with_data(&shared, worker)
}
for handle in workers {
thread.join(handle)
thread.destroy(handle)
}
fmt.println(shared.total)
} Putting the mutex inside the struct it guards is the convention worth adopting: a C#
lock (gate) leaves the association between lock and data implicit, and only a comment says which fields gate covers. Odin also has sync.atomic_add for the counter case, which needs no lock at all.Array of structs, struct of arrays
Adding
#soa to an array type changes the memory layout — all the x values become contiguous, then all the y values — while fast[index].x still reads exactly as before. The compiler rewrites each access.var particles = new Particle[4];
for (var index = 0; index < particles.Length; index++)
particles[index] = new Particle { X = index, Y = 0, Alive = true };
for (var index = 0; index < particles.Length; index++)
particles[index].X += 1;
Console.WriteLine(particles[2].X);
// Unity needed a whole second runtime — DOTS, NativeArray,
// and Burst — to express the struct-of-arrays layout that a
// C# array of structs cannot.
struct Particle { public float X; public float Y; public bool Alive; } package main
import "core:fmt"
Particle :: struct {
x: f32,
y: f32,
alive: bool,
}
main :: proc() {
// Ordinary array of structs: x,y,alive, x,y,alive, ...
regular: [4]Particle
// #soa stores each FIELD contiguously — all the xs, then
// all the ys — while the indexing syntax stays identical.
fast: #soa[4]Particle
for index in 0 ..< 4 {
regular[index] = Particle{f32(index), 0, true}
fast[index] = Particle{f32(index), 0, true}
}
for index in 0 ..< 4 {
regular[index].x += 1
fast[index].x += 1
}
fmt.println(regular[2].x, fast[2].x)
fmt.println(len(fast))
} For a Unity developer this is the headline. A loop touching only
x fills every cache line with useful data instead of skipping over y and alive, and that is precisely what DOTS, NativeArray, and Burst were built to achieve. Odin spells it as one directive on the type.Arrays that do arithmetic
Odin treats every fixed-size array as a mathematical vector:
+, -, * apply element-wise at any length, matrix[R, C]T is a builtin with true matrix multiplication, and .xy selects components by name.var left = new System.Numerics.Vector3(1, 2, 3);
var right = new System.Numerics.Vector3(10, 20, 30);
Console.WriteLine(left + right);
Console.WriteLine(System.Numerics.Vector3.Dot(left, right));
var transform = System.Numerics.Matrix4x4.Identity;
Console.WriteLine(transform.M11);
// System.Numerics gives you fixed sizes — Vector2, Vector3,
// Vector4, Matrix4x4 — with operators written by hand for
// each one. Arbitrary lengths need Vector<T> or a loop. package main
import "core:fmt"
import "core:math/linalg"
main :: proc() {
// Arithmetic on ANY fixed-size array is element-wise,
// built into the language, and allocates nothing.
left := [3]f32{1, 2, 3}
right := [3]f32{10, 20, 30}
fmt.println(left + right)
fmt.println(left * 2)
fmt.println(linalg.dot(left, right))
// matrix is a builtin type with real matrix multiply.
transform := matrix[2, 2]f32{
1, 2,
3, 4,
}
vector := [2]f32{1, 1}
fmt.println(transform * vector)
// Components can be swizzled by name:
position := [4]f32{1, 2, 3, 4}
fmt.println(position.xy, position.zw)
} This is one of the few places on the page where Odin is the terser language.
System.Numerics hand-writes operators for a handful of fixed sizes and leaves everything else to Vector<T> or a loop; here it is a property of the array type, so [7]f64 works the same way [3]f32 does and both compile to SIMD instructions.