Intermediate

Functions in F#

Learn how to define and use functions in F# - parameters, recursion, higher-order functions, currying, closures, and composition with Docker-ready examples

Functions are the beating heart of F#. As a functional-first language, F# treats functions as first-class values: they can be bound to names, passed as arguments, returned from other functions, partially applied, and composed together to build larger behavior from small pieces. There is no special “function declaration” ceremony — a function is just a value that happens to take an argument.

This functional foundation shapes everything about how you write F#. Where an imperative language reaches for loops and mutable counters, F# reaches for recursion and higher-order functions like List.map and List.fold. Where an object-oriented language wires behavior into methods on objects, F# composes plain functions with the pipe (|>) and composition (>>) operators.

In this tutorial you’ll learn how to define functions with the let keyword, how F#’s type inference figures out parameter and return types for you, and how recursion replaces loops. Then we’ll explore the features that make functional programming powerful: higher-order functions, currying and partial application, closures, and function composition.

Defining and Calling Functions

In F#, you define a function with let, list its parameters separated by spaces, and use = to introduce the body. There are no parentheses around the parameter list and no return keyword — the last expression evaluated is the return value.

Create a file named functions.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Basic function: type inference figures out 'x' is an int
let square x = x * x

// Multiple parameters are separated by spaces
let add a b = a + b

// Explicit type annotations (usually optional thanks to inference)
let greet (name: string) : string =
    sprintf "Hello, %s!" name

// A multi-line function with a local binding in its body
let circleArea radius =
    let pi = 3.14159
    pi * radius * radius

// A function returning unit performs a side effect only
let printResult label value =
    printfn "%s = %d" label value

// Functions are called by writing the name followed by arguments
printResult "square 5" (square 5)
printResult "add 3 4" (add 3 4)
printfn "%s" (greet "F#")
printfn "circleArea 2.0 = %f" (circleArea 2.0)

Notice how square and add need no type annotations — F# infers that they work on int because of the * and + operations. The parentheses in calls like (square 5) group the expression so its result is passed as a single argument; F# uses spaces, not commas, to separate arguments.

Recursion

F# discourages mutable loop variables, so recursion is the natural way to repeat work. A function that calls itself must be marked with the rec keyword — this is required and the compiler will tell you if you forget it.

Create a file named functions_recursion.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 'rec' is required for a function to refer to itself
let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)

// Recursion pairs naturally with pattern matching
let rec fib n =
    match n with
    | 0 -> 0
    | 1 -> 1
    | _ -> fib (n - 1) + fib (n - 2)

// Tail-recursive sum using an accumulator and a nested helper
let sumTo n =
    let rec loop acc i =
        if i > n then acc
        else loop (acc + i) (i + 1)
    loop 0 1

printfn "factorial 5 = %d" (factorial 5)
printfn "fib 10 = %d" (fib 10)
printfn "sumTo 100 = %d" (sumTo 100)

The sumTo example shows an important pattern: the inner loop function carries an accumulator, so the recursive call is the very last thing it does. This tail recursion lets the F# compiler reuse the stack frame, avoiding stack overflow even for large inputs — the functional equivalent of a for loop.

Higher-Order Functions, Currying, and Composition

This is where F# shines. Functions can take other functions as arguments and return new functions. Because all F# functions are curried by default, supplying fewer arguments than a function expects gives you back a new function waiting for the rest — this is partial application.

Create a file named functions_higher_order.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// A lambda (anonymous function) bound to a name
let double = fun x -> x * 2

// Higher-order function: it takes a function 'f' as a parameter
let applyTwice f x = f (f x)

// Currying: supplying one argument returns a new function
let add a b = a + b
let add10 = add 10          // partial application -> int -> int

// A closure captures a variable from its surrounding scope
let makeCounter () =
    let mutable count = 0
    fun () ->
        count <- count + 1
        count

// The pipe operator |> feeds a value into the next function
let numbers = [1; 2; 3; 4; 5]
let sumOfSquares =
    numbers
    |> List.map (fun x -> x * x)
    |> List.sum

// Function composition with >> builds a new function from two others
let addThenDouble = add10 >> double

printfn "applyTwice double 3 = %d" (applyTwice double 3)
printfn "add10 5 = %d" (add10 5)
printfn "sumOfSquares = %d" sumOfSquares
printfn "addThenDouble 5 = %d" (addThenDouble 5)

let counter = makeCounter ()
printfn "counter: %d %d %d" (counter ()) (counter ()) (counter ())

A few things to highlight: applyTwice double 3 evaluates double (double 3), doubling twice. add10 is add with its first argument fixed at 10. The pipe operator turns nested calls inside-out into a readable top-to-bottom data flow, and >> glues two functions into a single new one where the output of the first becomes the input of the second.

Running with Docker

These are F# script files, so we run them with F# Interactive (dotnet fsi) inside the official .NET SDK image — no local install required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pull the official .NET SDK image
docker pull mcr.microsoft.com/dotnet/sdk:9.0

# Run the basic functions example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi functions.fsx

# Run the recursion example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi functions_recursion.fsx

# Run the higher-order functions example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi functions_higher_order.fsx

Expected Output

Running functions.fsx:

square 5 = 25
add 3 4 = 7
Hello, F#!
circleArea 2.0 = 12.566360

Running functions_recursion.fsx:

factorial 5 = 120
fib 10 = 55
sumTo 100 = 5050

Running functions_higher_order.fsx:

applyTwice double 3 = 12
add10 5 = 15
sumOfSquares = 55
addThenDouble 5 = 30
counter: 1 2 3

Key Concepts

  • Functions are valueslet name args = body defines a function exactly the way let x = 5 defines a value. There is no return keyword; the last expression is the result.
  • Type inference — F# usually infers parameter and return types from how they’re used, so explicit annotations are optional and reserved for clarity or disambiguation.
  • rec is required for recursion — a function that calls itself must be declared with let rec. Use an accumulator to make recursion tail-recursive and stack-safe, replacing imperative loops.
  • Everything is curried — multi-argument functions are really chains of single-argument functions, which makes partial application (add 10) free and natural.
  • Higher-order functions — functions can take and return other functions; List.map, List.filter, and List.fold are the idiomatic alternatives to writing loops.
  • Closures — an inner function captures variables from its enclosing scope, letting you build stateful helpers like counters.
  • Pipe and compose|> feeds a value through a sequence of transformations for readable data flow, while >> combines functions into a single reusable function.

Running Today

All examples can be run using Docker:

docker pull mcr.microsoft.com/dotnet/sdk:9.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining