Lesson 4: Types, Type Inference, and Discriminated Unions
Deep dive into F#'s powerful type system, exploring type inference, custom types, and discriminated unions for modeling complex data
Lesson 4: Types, Type Inference, and Discriminated Unions
F# has a sophisticated type system that distinguishes it from many other programming languages. Unlike languages that require explicit type annotations everywhere, F# employs type inference, which automatically deduces types from your code. This creates a unique balance: you get the safety and performance benefits of static typing without the verbosity often associated with statically-typed languages. The compiler analyzes your expressions, function definitions, and variable assignments to determine types at compile time, catching errors early while keeping your code clean and readable. This type inference is remarkably powerful—it can deduce complex generic types, function signatures, and even recursive type structures with minimal hints from the programmer.
💡 Tip: While F# can infer most types automatically, you can always add explicit type annotations for clarity or to constrain generic types. Use the syntax
(value: Type)orlet functionName (param: Type) : ReturnType = ...
The foundation of F#'s type system includes primitive types like int, float, string, bool, and char. Beyond these basics, F# provides tuples for grouping values together without creating named types. A tuple like (42, "answer", true) has the inferred type int * string * bool. Tuples are lightweight and perfect for returning multiple values from functions or temporarily grouping related data. You can deconstruct tuples using pattern matching: let (x, y, z) = (1, 2, 3) assigns each component to a separate variable. F# also supports records, which are immutable, structural types with named fields. Records provide better readability than tuples when you need to work with structured data that has meaningful field names.
// Type inference examples
let age = 25 // inferred as int
let price = 19.99 // inferred as float
let name = "Alice" // inferred as string
let isActive = true // inferred as bool
// Tuple example
let coordinates = (10.5, 20.3) // inferred as float * float
let person = ("Bob", 30, true) // inferred as string * int * bool
// Destructuring tuples
let (x, y) = coordinates
let (name, age, employed) = person
// Record type definition
type Person = {
Name: string
Age: int
Email: string
}
// Creating a record instance
let alice = { Name = "Alice"; Age = 28; Email = "alice@example.com" }
Discriminated unions (also called algebraic data types or sum types) are one of F#'s most powerful features for modeling domains. A discriminated union defines a type that can be one of several named cases, each potentially carrying different associated data. This is fundamentally different from classes or structs—instead of representing a single entity, a discriminated union represents a choice among alternatives. For example, a Shape type might be either a Circle with a radius, a Rectangle with width and height, or a Triangle with three sides. This approach allows you to model real-world scenarios where something can be one of multiple distinct possibilities, making your code more expressive and type-safe.
// Simple discriminated union
type PaymentMethod =
| Cash
| CreditCard of cardNumber: string * expiryDate: string
| DebitCard of cardNumber: string
| PayPal of email: string
// Creating union instances
let payment1 = Cash
let payment2 = CreditCard("1234-5678-9012-3456", "12/25")
let payment3 = PayPal "user@example.com"
// Shape union with different data for each case
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of side1: float * side2: float * side3: float
let myCircle = Circle 5.0
let myRectangle = Rectangle(10.0, 20.0)
🎯 Key Takeaway: Discriminated unions allow you to represent data that can be "one of" several possibilities, with each case optionally carrying its own specific data. This is perfect for modeling state machines, abstract syntax trees, business rules, and domain models.
Pattern matching becomes incredibly powerful when combined with discriminated unions. You can exhaustively match on all possible cases of a union, and the compiler will warn you if you forget to handle a case. This ensures your code handles all possibilities, preventing runtime errors. When pattern matching on unions, you can extract the associated data directly in the match pattern. This combination of discriminated unions and pattern matching enables you to write code that's both concise and provably correct—the compiler verifies you've considered every scenario.
// Pattern matching on discriminated unions
let describePayment payment =
match payment with
| Cash -> "Payment in cash"
| CreditCard(number, expiry) ->
sprintf "Credit card ending in %s, expires %s" (number.Substring(number.Length - 4)) expiry
| DebitCard(number) ->
sprintf "Debit card ending in %s" (number.Substring(number.Length - 4))
| PayPal(email) ->
sprintf "PayPal account: %s" email
// Calculating area using pattern matching
let calculateArea shape =
match shape with
| Circle radius -> System.Math.PI * radius * radius
| Rectangle(width, height) -> width * height
| Triangle(a, b, c) ->
// Heron's formula
let s = (a + b + c) / 2.0
sqrt(s * (s - a) * (s - b) * (s - c))
let area1 = calculateArea (Circle 5.0) // ~78.54
let area2 = calculateArea (Rectangle(4.0, 6.0)) // 24.0
⚠️ Common Mistake: Forgetting to handle all cases in a pattern match on a discriminated union will cause a compiler warning. Always handle every case or use a wildcard pattern
_as a catch-all, though explicit case handling is generally preferred for safety.
Option types are a special discriminated union built into F# that represents the presence or absence of a value. The Option<'T> type has two cases: Some value when a value exists, or None when it doesn't. This is F#'s type-safe alternative to null references, eliminating entire classes of null reference exceptions. Instead of returning null when a value might not exist, F# functions return Option types, forcing calling code to explicitly handle both the success and failure cases. This makes your code more robust and self-documenting—the function signature itself tells you whether a value is guaranteed or optional.
// Option type examples
let tryDivide x y =
if y = 0.0 then
None
else
Some(x / y)
let result1 = tryDivide 10.0 2.0 // Some 5.0
let result2 = tryDivide 10.0 0.0 // None
// Pattern matching on Option
let describeResult result =
match result with
| Some value -> sprintf "Result: %.2f" value
| None -> "Division by zero - no result"
// Finding element in list returns Option
let numbers = [1; 2; 3; 4; 5]
let found = List.tryFind (fun x -> x > 3) numbers // Some 4
let notFound = List.tryFind (fun x -> x > 10) numbers // None
Recursive discriminated unions allow you to define types that reference themselves, which is essential for modeling tree-like or hierarchical data structures. A classic example is a binary tree, where each node is either a leaf (empty) or a branch containing a value and two subtrees. This recursive definition mirrors the mathematical definition of recursive data structures. You can also use recursive unions to model abstract syntax trees for parsers, file system hierarchies, linked lists, or any domain where entities contain other entities of the same type.
// Recursive binary tree definition
type BinaryTree<'T> =
| Empty
| Node of value: 'T * left: BinaryTree<'T> * right: BinaryTree<'T>
// Creating a binary tree
let tree =
Node(5,
Node(3,
Node(1, Empty, Empty),
Node(4, Empty, Empty)),
Node(8,
Node(6, Empty, Empty),
Node(9, Empty, Empty)))
// Recursive function to count nodes
let rec countNodes tree =
match tree with
| Empty -> 0
| Node(_, left, right) -> 1 + countNodes left + countNodes right
let nodeCount = countNodes tree // 7
// Recursive function to search tree
let rec contains value tree =
match tree with
| Empty -> false
| Node(v, left, right) ->
if value = v then true
elif value < v then contains value left
else contains value right
🔍 Deep Dive: When working with recursive discriminated unions, always ensure your recursive functions have a base case (like
Emptyfor trees) to prevent infinite recursion. The pattern matching structure naturally guides you to handle both the base case and recursive cases.
F#'s type system also supports type abbreviations (type aliases) and single-case discriminated unions for creating domain-specific types. A type abbreviation like type EmailAddress = string creates an alias for an existing type, improving code readability. However, this doesn't provide type safety—you can still pass any string where an EmailAddress is expected. In contrast, a single-case discriminated union like type EmailAddress = EmailAddress of string creates a distinct type that wraps the string. This provides compile-time safety—you cannot accidentally pass a regular string where an EmailAddress is expected, forcing you to explicitly construct the wrapped type. This technique, called type-driven design, helps prevent bugs by making invalid states unrepresentable.
// Type abbreviation (alias) - less safe
type EmailAddress = string
type CustomerId = int
let email: EmailAddress = "user@example.com"
let id: CustomerId = 12345
// Can still mix these up: compiler won't complain
// Single-case discriminated union - type safe
type SafeEmailAddress = SafeEmailAddress of string
type SafeCustomerId = SafeCustomerId of int
let safeEmail = SafeEmailAddress "user@example.com"
let safeId = SafeCustomerId 12345
// Function expecting specific type
let sendEmail (SafeEmailAddress email) =
printfn "Sending email to: %s" email
// This works
sendEmail safeEmail
// This would cause a compile error
// sendEmail "user@example.com" // Error: type mismatch
Generic types in F# allow you to write flexible, reusable code that works with any type. F# uses type parameters denoted by 'T, 'U, etc. (pronounced "tick-T"). The compiler can often infer these generic types automatically based on usage. You've already seen generic types with Option<'T>, List<'T>, and BinaryTree<'T>. You can create your own generic discriminated unions and records to build type-safe, reusable abstractions. Generic types combined with F#'s type inference create a powerful system where you get flexibility without sacrificing type safety.
// Generic discriminated union for result types
type Result<'TSuccess, 'TFailure> =
| Success of 'TSuccess
| Failure of 'TFailure
// Generic function with inferred types
let mapResult f result =
match result with
| Success value -> Success(f value)
| Failure error -> Failure error
// Usage example
type ValidationError =
| InvalidEmail
| PasswordTooShort
| UsernameTaken
let validateAge age =
if age >= 18 then
Success age
else
Failure "Must be 18 or older"
let result = validateAge 25 // Success 25
let doubled = mapResult (fun x -> x * 2) result // Success 50
📝 Try This: Create a discriminated union to model different types of vehicles (Car, Motorcycle, Truck) where each carries relevant data (passenger count, cargo capacity, etc.). Then write a function using pattern matching to calculate road tax based on the vehicle type.
The combination of type inference, discriminated unions, and pattern matching creates a programming style that's both safe and expressive. You can model complex domains precisely, the compiler verifies correctness, and the code remains readable without excessive type annotations. This is particularly valuable in domains like compilers (abstract syntax trees), parsers (token types), state machines (state representations), financial systems (transaction types), and game development (entity types). By making invalid states unrepresentable through careful type design, you push many potential bugs from runtime to compile time, where they're caught immediately.
📚 Further Study
- F# for Fun and Profit - Discriminated Unions - Comprehensive guide to discriminated unions with practical examples and best practices
- Microsoft F# Language Reference - Types - Official documentation covering all F# type system features
- Domain Modeling Made Functional - Book by Scott Wlaschin on using F#'s type system for domain-driven design
- F# Type Inference - Microsoft Docs - Deep dive into how F#'s type inference algorithm works
- Option Types Explained - F# Foundation - Tutorial on using Option types to eliminate null reference errors