← Back to DevBytes

F# Type System: Static vs Dynamic Typing

Understanding F#'s Type System: Static Typing, Type Inference, and Dynamic Interop

F# is fundamentally a statically typed language built on the .NET runtime. Every expression, function, and value has a known type at compile time. This gives you strong safety guarantees, excellent performance, and rich editor tooling. Yet F# also provides controlled bridges into dynamic typing through .NET interop, reflection, and a unique feature called type providers. Understanding where static typing shines, when dynamic approaches become necessary, and how to blend them effectively is essential for every F# developer.

What Static Typing Means in F#

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Static typing means the compiler verifies all type relationships before your program ever runs. In F#, this verification is remarkably unobtrusive thanks to type inference—you rarely need to annotate types explicitly, yet the compiler still enforces complete type safety.

Type Inference in Action

Consider this simple function. No type annotations are present, yet the compiler deduces everything:

// The compiler infers: int -> int -> int
let add x y = x + y

// The compiler infers: string -> string -> string  
let greet salutation name = salutation + " " + name

// The compiler infers: 'a list -> 'a list (generic!)
let reverse list = List.rev list

The inferred types are precise. add works only with integers because the + operator is resolved at compile time. greet works only with strings. And reverse becomes generic—the 'a type parameter means it works with lists of any element type. The compiler figures all of this out from usage alone.

Explicit Type Annotations

Sometimes you want to constrain types more narrowly than inference would allow. Explicit annotations serve as documentation and guardrails:

// Restrict to float instead of generic numeric type
let square (x : float) : float = x * x

// Explicit generic with constraints
let inline doubleIt (x : ^a) : ^a when ^a : (static member (*) : ^a -> int -> ^a) =
    x * 2

// Record type with explicit field types
type Customer = {
    Id : int
    Name : string
    CreatedAt : System.DateTime
}

Discriminated Unions and Pattern Matching

F#'s algebraic type system goes far beyond classes and interfaces. Discriminated unions let you model mutually exclusive cases with compiler-verified exhaustiveness:

type PaymentMethod =
    | Cash
    | CreditCard of cardNumber : string * expiryMonth : int * expiryYear : int
    | BankTransfer of accountNumber : string * routingCode : string
    | Crypto of walletAddress : string

let describePayment (method : PaymentMethod) : string =
    match method with
    | Cash -> "Paying with physical currency"
    | CreditCard(number, month, year) ->
        let masked = "****-****-****-" + number.Substring(number.Length - 4)
        sprintf "Credit card ending in %s, expires %02d/%d" masked month year
    | BankTransfer(acct, routing) ->
        sprintf "Bank transfer to account %s, routing %s" acct routing
    | Crypto(address) ->
        sprintf "Cryptocurrency transfer to wallet %s...%s"
            (address.Substring(0, 6))
            (address.Substring(address.Length - 4))

// Compiler warns if you forget a case—exhaustiveness is enforced!

The compiler checks every match expression for completeness. If you add a new case to PaymentMethod, every match expression across your entire codebase lights up with warnings until you handle it. This is static typing at its most powerful—the type system actively guides your refactoring.

Option Types Eliminate Null Reference Errors

F# uses Option<'a> instead of null. The compiler forces you to handle both Some and None cases:

type User = {
    Id : int
    Email : string
    PhoneNumber : string option  // might be missing
}

let formatContact (user : User) : string =
    let phoneDisplay =
        match user.PhoneNumber with
        | Some number -> number
        | None -> "no phone on file"
    sprintf "%s (phone: %s)" user.Email phoneDisplay

// This would be a compile error—must handle None:
// let badPhone = user.PhoneNumber.Value  // compile-time error!

Why Static Typing Matters in Practice

The benefits extend far beyond academic type theory. Here's what you gain day to day:

Dynamic Typing Scenarios in F#

F# itself is statically typed, but it runs on the .NET Common Language Runtime, which provides dynamic features. Several scenarios call for dynamic typing, and F# handles them gracefully.

Interoperating with C# dynamic

C# has a dynamic keyword that defers type resolution to runtime. F# can consume dynamic objects via the System.Dynamic namespace or by using the ? operator when the necessary language features are enabled:

// Using System.Dynamic.ExpandoObject
open System.Dynamic

let buildDynamicConfig () =
    let config = ExpandoObject() :> IDictionary
    config.["timeout"] <- 30
    config.["retryCount"] <- 3
    config.["endpointUrl"] <- "https://api.example.com/v2"
    config

let readDynamicValue (config : IDictionary) (key : string) =
    match config.TryGetValue(key) with
    | true, value -> Some value
    | false, _ -> None

let config = buildDynamicConfig ()
let timeout = readDynamicValue config "timeout"

match timeout with
| Some t -> printfn "Timeout setting: %O" t
| None -> printfn "Timeout not configured"

Working with JSON and Untyped Data

When consuming external APIs, you often deal with data whose shape isn't known until runtime. F# offers both statically typed and dynamic approaches:

// Dynamic approach: parse JSON into untyped tree
open System.Text.Json

let json = """
{
  "users": [
    {"name": "Alice", "role": "admin"},
    {"name": "Bob", "role": "editor"}
  ],
  "pagination": {"page": 1, "total": 42}
}
"""

let doc = JsonDocument.Parse(json)
let root = doc.RootElement

// Dynamic traversal—no compile-time shape guarantees
let firstUserName =
    root.GetProperty("users")[0].GetProperty("name").GetString()

printfn "First user: %s" firstUserName

// But property names are strings—typos cause runtime errors!
// root.GetProperty("usrs")  // throws KeyNotFoundException at runtime

Static Typing for JSON with Type Providers

Type providers bridge static and dynamic worlds. They read an external schema (JSON sample, database, CSV headers) at compile time and generate types that the compiler then enforces:

// Using FSharp.Data type provider
#r "nuget: FSharp.Data"
open FSharp.Data

// At compile time, the type provider reads the sample JSON
// and generates User, Root, etc. types with proper fields
type SampleJson = JsonProvider<"""
[
  {"name": "Alice", "role": "admin", "age": 30},
  {"name": "Bob", "role": "editor", "age": 25}
]
""">

// Now you get full static typing with autocomplete!
let parseAndProcess (jsonString : string) =
    let data = SampleJson.Parse(jsonString)
    
    // Compiler knows the shape—autocomplete works on .Name, .Role, .Age
    let summary =
        data
        |> Array.map (fun user ->
            sprintf "%s (%s, age %d)" user.Name user.Role user.Age)
        |> String.concat "; "
    
    summary

// This would be a compile error:
// let typo = data.[0].Nmae  // compile-time error, not runtime!

The type provider reads the JSON sample at compile time, generates types, and gives you full static checking against the inferred schema. If the actual runtime JSON has a different shape, you still get runtime errors—but all your code that processes the data is statically verified.

Reflection-Based Dynamic Access

F# provides excellent reflection support for scenarios where you genuinely need runtime type discovery:

open System.Reflection

type Person = {
    FirstName : string
    LastName : string
    Age : int
}

let dynamicallyGetProperty (instance : obj) (propertyName : string) =
    let ty = instance.GetType()
    let prop = ty.GetProperty(propertyName)
    match prop with
    | null -> 
        Error (sprintf "Property '%s' not found on type '%s'" propertyName ty.Name)
    | p ->
        let value = p.GetValue(instance)
        Ok value

let person = { FirstName = "Jane"; LastName = "Doe"; Age = 28 }

// Works, but errors caught only at runtime
match dynamicallyGetProperty person "FirstName" with
| Ok v -> printfn "FirstName = %O" v
| Error e -> printfn "Error: %s" e

match dynamicallyGetProperty person "MiddleName" with
| Ok v -> printfn "MiddleName = %O" v
| Error e -> printfn "Error: %s" e  // runtime error—no MiddleName field

How to Choose: Static vs Dynamic Approaches

The decision framework is straightforward:

Pattern: Dynamic Boundary, Static Core

This is the most robust architecture. Parse and validate at the edges, then work with strongly typed domain models everywhere else:

// Dynamic boundary: parse raw HTTP form data
let parseFormData (formValues : (string * string) list) =
    formValues
    |> List.map (fun (key, rawValue) ->
        match key, rawValue with
        | "email", v -> Ok (EmailField v)
        | "age", v ->
            match System.Int32.TryParse(v) with
            | true, age -> Ok (AgeField age)
            | false, _ -> Error (sprintf "Invalid age: %s" v)
        | other, _ -> Error (sprintf "Unknown field: %s" other)
    )

// Static core: domain types
type ValidatedField =
    | EmailField of string
    | AgeField of int

type ValidatedForm = {
    Email : string
    Age : int
    Errors : string list
}

let buildDomainModel (results : Result<ValidatedField, string> list) : ValidatedForm =
    let values = results |> List.choose (function Ok v -> Some v | Error _ -> None)
    let errors = results |> List.choose (function Error e -> Some e | Ok _ -> None)
    
    let email = 
        values 
        |> List.tryPick (function EmailField e -> Some e | _ -> None)
        |> Option.defaultValue ""
    
    let age =
        values
        |> List.tryPick (function AgeField a -> Some a | _ -> None)
        |> Option.defaultValue 0
    
    { Email = email; Age = age; Errors = errors }

// Now all downstream code works with ValidatedForm statically!

Advanced Static Typing Features

Units of Measure

F# can track physical units in the type system, preventing unit-mismatch bugs at compile time with zero runtime cost:

[<Measure>] type m
[<Measure>] type s
[<Measure>] type kg

let distance : float<m> = 100.0<m>
let time : float<s> = 9.8<s>
let mass : float<kg> = 70.0<kg>

let speed = distance / time  // inferred: float<m/s>
let momentum = mass * speed  // inferred: float<kg * m/s>

// Compile error—can't add quantities with different units:
// let nonsense = distance + mass  // type mismatch!

Statically Resolved Type Parameters

The inline keyword combined with ^ type parameters enables compile-time duck typing without losing static safety:

// Works on any type that has a .Name property and an Age member
let inline printPerson (x : ^T) =
    let name = (^T : (member Name : string) x)
    let age = (^T : (member Age : int) x)
    sprintf "%s is %d years old" name age

type Employee = { Name : string; Age : int; Department : string }
type Student = { Name : string; Age : int; Grade : float }

let emp = { Name = "Carlos"; Age = 42; Department = "Engineering" }
let stu = { Name = "Aisha"; Age = 20; Grade = 3.8 }

printfn "%s" (printPerson emp)  // "Carlos is 42 years old"
printfn "%s" (printPerson stu)  // "Aisha is 20 years old"

// This is fully statically resolved—no reflection, no runtime dispatch
// The compiler generates specialized code for each call site

Best Practices Summary

Conclusion

F#'s type system gives you the best of both worlds: the safety, performance, and refactoring confidence of static typing, plus pragmatic escape hatches into dynamic territory when you need them. The compiler's type inference eliminates the ceremony that makes static typing feel heavy in other languages, while discriminated unions, option types, and units of measure let you encode business rules directly into types that the compiler enforces. When you must deal with runtime-shaped data, type providers offer a uniquely F# solution—dynamic schema reading at compile time that produces fully statically checked code. By keeping your core domain strictly typed and confining dynamic operations to well-tested boundary layers, you build systems that are both flexible at the edges and rock-solid at the center.

🚀 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