← Back to DevBytes

Functional Programming in Odin

Functional Programming in Odin

Odin is a systems programming language that prioritises simplicity, performance, and explicit control. While it is not a purely functional language, it provides first‑class support for many functional programming techniques. You can write code that leans heavily on immutable values, pure functions, higher‑order procedures, and composable abstractions—without giving up the low‑level power Odin is known for. This tutorial walks you through the core concepts, practical examples, and best practices for applying a functional style in your Odin codebases.

What Is Functional Programming in Odin?

Functional programming (FP) is a paradigm where computation is treated as the evaluation of mathematical functions, avoiding mutable state and side effects. In Odin, you have all the necessary building blocks:

Odin won’t enforce referential transparency or forbid side effects, but you can adopt a discipline that keeps functions pure and data transformations explicit. This hybrid approach gives you the best of both worlds: the clarity of FP with the performance characteristics of a compiled systems language.

Why Functional Programming Matters in Systems Programming

Even in a domain where manual memory management and direct hardware access are common, functional style brings tangible benefits:

In Odin, adopting these principles does not mean sacrificing speed. The compiler inlines aggressively, slices and structs are value types that are cheap to copy, and well‑written functional code often results in tight, cache‑friendly loops.

First‑Class Functions and Higher‑Order Procedures

The foundation of functional programming in Odin is the ability to treat procedures as ordinary values. Let’s start with a classic example: a map function that applies a transformation to every element of an integer slice.

package main

import "core:fmt"

// map_int takes a slice and a transformation function, returns a new slice.
map_int :: proc(data: []int, f: proc(int) -> int) -> []int {
    result := make([]int, len(data))
    for i in 0..<len(data) {
        result[i] = f(data[i])
    }
    return result
}

main :: proc() {
    numbers := []int{1, 2, 3, 4}
    // Pass an anonymous proc that squares its input.
    squared := map_int(numbers, proc(n: int) -> int { return n * n })
    fmt.println(squared) // [1, 4, 9, 16]
}

Here, map_int is a higher‑order procedure: it takes another procedure as a parameter. The anonymous proc proc(n: int) -> int { return n * n } is a lambda that we pass directly at the call site. Notice how the type signature of map_int clearly describes the transformation: f: proc(int) -> int. This pattern scales to any data type and any transformation logic.

Returning a procedure from another procedure is just as natural. For instance, you can create a factory that builds an “adder” function:

make_adder :: proc(increment: int) -> proc(int) -> int {
    return proc(x: int) -> int {
        return x + increment
    }
}

main :: proc() {
    add_five := make_adder(5)
    fmt.println(add_five(10)) // 15
}

The returned closure captures increment by reference (Odin’s default for captured local variables). When a closure outlives the scope of the captured variables, the compiler automatically heap‑allocates the closure and its captured data, so you don’t have to worry about manual allocation for simple escaping closures.

Closures and Capturing Context

A closure is an anonymous procedure that “closes over” variables from its lexical environment. This is incredibly useful for creating stateful helpers without resorting to global variables or complex structs. A classic example is a counter generator:

make_counter :: proc() -> proc() -> int {
    count := 0
    return proc() -> int {
        count += 1
        return count
    }
}

main :: proc() {
    counter_a := make_counter()
    counter_b := make_counter()
    fmt.println(counter_a()) // 1
    fmt.println(counter_a()) // 2
    fmt.println(counter_b()) // 1  (independent state)
}

Each call to make_counter creates a fresh count variable, and the returned closure owns that variable. Because captured variables are references, the closure mutates the original count safely. Just be mindful: if you pass such a closure to a function that stores it beyond the lifetime of the original stack frame, Odin will promote it to the heap automatically. This is usually what you want, but in extremely tight loops you might prefer an explicit struct to keep everything on the stack.

Implementing Filter and Reduce

With the same functional building blocks, you can implement other staple combinators. Here are filter_int and reduce_int that work on integer slices:

filter_int :: proc(data: []int, pred: proc(int) -> bool) -> []int {
    result: [dynamic]int
    for val in data {
        if pred(val) {
            append(&result, val)
        }
    }
    return result[:]
}

reduce_int :: proc(data: []int, initial: int, combiner: proc(int, int) -> int) -> int {
    acc := initial
    for val in data {
        acc = combiner(acc, val)
    }
    return acc
}

main :: proc() {
    nums := []int{1, 2, 3, 4, 5}
    evens := filter_int(nums, proc(n: int) -> bool { return n % 2 == 0 })
    sum := reduce_int(nums, 0, proc(a, b: int) -> int { return a + b })
    fmt.println(evens, sum) // [2, 4], 15
}

These functions are pure: they do not modify the input slice, and they always return a predictable result. filter_int uses a dynamic array internally for convenience, but the final slice is a plain []int. Because the transformation is described by the passed‑in predicate or combiner, the same functions can handle any selection or aggregation logic without code duplication.

Using Functional Composition

One of the most powerful FP techniques is composition—building a new function by chaining existing ones. You can write a generic compose helper that takes two functions and returns their composition:

compose :: proc(f: proc(int) -> int, g: proc(int) -> int) -> proc(int) -> int {
    return proc(x: int) -> int {
        return f(g(x))
    }
}

main :: proc() {
    add_one :: proc(x: int) -> int { return x + 1 }
    square :: proc(x: int) -> int { return x * x }
    
    // Compose square after add_one: (x + 1)^2
    add_then_square := compose(square, add_one)
    fmt.println(add_then_square(3)) // 16
    
    // You can also compose more than two functions manually.
    double :: proc(x: int) -> int { return x * 2 }
    double_then_add_one := compose(add_one, double)
    fmt.println(double_then_add_one(5)) // 11
}

This pattern encourages you to build complex operations out of small, single‑responsibility functions. The resulting code reads almost like a pipeline declaration, and each component can be tested in isolation.

Working with Immutable Data Structures

Odin’s value semantics already nudge you toward immutability: slices, structs, and arrays are copied when passed by value. You can strengthen the functional style by marking parameters and local variables as const and by preferring functions that return new values rather than modifying their arguments in place.

// Pure function that returns a new slice with an element appended.
append_sorted :: proc(sorted: []int, value: int) -> []int {
    // We do not modify `sorted`; we build a new slice.
    result := make([]int, len(sorted) + 1)
    copy(result, sorted)
    result[len(sorted)] = value
    // Optionally sort here…
    return result
}

main :: proc() {
    base := []int{1, 3, 5}
    extended := append_sorted(base, 2)
    fmt.println(base)     // [1, 3, 5]  (unchanged)
    fmt.println(extended) // [1, 3, 5, 2]
}

Even when performance demands in‑place updates (for example, inside a tight loop), try to isolate those mutations inside a clearly named procedure and keep the rest of your code pure. This separation makes it easy to identify and audit side effects.

Best Practices for Functional Odin Code

Conclusion

Functional programming in Odin is a practical, powerful approach that sits comfortably alongside the language’s systems‑level strengths. By treating procedures as values, using closures for encapsulated state, and building data transformations out of pure combinators like map, filter, and reduce, you can write code that is both expressive and highly performant. The techniques shown here—first‑class functions, composition, immutability, and higher‑order iteration—will help you craft clearer, more maintainable Odin programs. Start applying these patterns gradually in your own projects, and you’ll quickly see how functional thinking improves even the most low‑level code.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles