Working with F# Interactive (FSI): A Development Workflow

F# Interactive (FSI) has fundamentally changed how I approach software development. Rather than the traditional edit-compile-run cycle, FSI enables a direct, conversational/interactive approach to coding that eliminates friction and accelerates development. This post explores the practical advantages I’ve discovered through years of FSI-first development using F# script files.

Script-Based Development with Immediate Feedback

My preferred approach is working with .fsx files that can be executed directly in FSI. There’s no project setup, no build configuration, no waiting for compilation. You write code in a script file and execute it instantly:

// factorial.fsx
let factorial n = 
    let rec loop acc i = 
        if i <= 1 then acc 
        else loop (acc * i) (i - 1)
    loop 1 n

// Test the function
let result = factorial 5
printfn "5! = %d" result

Execute with: dotnet fsi factorial.fsx. Or, directly send it to the FSI using for example VSCode or Rider.

Output:

5! = 120

This immediacy fundamentally changes how you think about code. Instead of writing large blocks and hoping they work, you build incrementally in script files, verifying each step. The feedback loop is measured in seconds, not minutes.

Note: you can even select specific parts of the code and evaluate only that selection directly in the FSI.

Exploring Existing Codebases

One of FSI’s most powerful features is its ability to load and interact with existing assemblies and source files. When working with an unfamiliar codebase, I create exploration scripts that load modules and experiment with functions:

// explore.fsx
#load "DataProcessor.fs"
open DataProcessor

let sampleData = [1; 2; 3; 4; 5]
let result = processData sampleData
printfn "Sample data: %A" sampleData
printfn "Processed result: %A" result

// Experiment with edge cases
let emptyResult = processData []
printfn "Empty input result: %A" emptyResult

// Test with different data types
let stringData = ["a"; "b"; "c"]
// This will show compile error if processData doesn't handle strings

Execute with: dotnet fsi explore.fsx

This exploratory approach is invaluable for understanding legacy code or third-party libraries. You can probe functions with various inputs, examine their behavior, and build understanding through experimentation rather than static code reading.

Module Shadowing for Safe Refactoring

FSI’s module system allows you to shadow existing modules with new implementations. This enables safe refactoring directly in script files:

// refactor-test.fsx
// Load original module
#load "Calculator.fs"

// Test original behavior
let originalResult = Calculator.add 5 3
printfn "Original add result: %d" originalResult

// Shadow with improved implementation
module Calculator =
    let add x y = 
        printfn "Adding %d and %d" x y
        x + y

    let multiply x y = 
        printfn "Multiplying %d and %d" x y
        x * y

// Test new implementation
printfn "Testing new implementation:"
let newResult = Calculator.add 5 3
printfn "New add result: %d" newResult

let multiplyResult = Calculator.multiply 4 7
printfn "Multiply result: %d" multiplyResult

Execute with: dotnet fsi refactor-test.fsx

The shadowed module completely replaces the original in the script execution. You can test the new implementation thoroughly before committing changes to source files. This approach eliminates the fear of breaking existing code during refactoring.

My Development Workflow

My typical development process follows this pattern:

  1. Create an .fsx script file for experimentation
  2. Load relevant modules and experiment with data structures
  3. Build functions incrementally, testing each piece
  4. Refactor and optimize in the script environment
  5. Copy the final, tested code to source files

Here’s a concrete example of building a simple text parser:

// text-parser.fsx
let input = "name:John,age:30,city:Amsterdam"

// Build parsing functions incrementally
let splitPairs (s: string) = s.Split(',')

// Test it immediately
let pairs = splitPairs input
printfn "Split pairs: %A" pairs

let parseKeyValue (pair: string) =
    match pair.Split(':') with
    | [|key; value|] -> Some (key.Trim(), value.Trim())
    | _ -> None

// Test parsing
let parsedPairs = pairs |> Array.map parseKeyValue 
printfn "Parsed pairs: %A" parsedPairs

// Combine into final function
let parseConfig input =
    input
    |> splitPairs
    |> Array.choose parseKeyValue
    |> Map.ofArray

// Final test
let config = parseConfig input
printfn "Final config: %A" config
printfn "Name: %s" config.["name"]
printfn "Age: %s" config.["age"]
printfn "City: %s" config.["city"]

Execute with: dotnet fsi text-parser.fsx

Each step is validated immediately. By the time I copy this code to a source file, I know it works correctly.

Advanced FSI Features

Loading External Dependencies

FSI scripts can load NuGet packages and external assemblies directly:

// json-example.fsx
#r "nuget: Newtonsoft.Json"
open Newtonsoft.Json

let data = {| name = "John"; age = 30; city = "Amsterdam" |}
let json = JsonConvert.SerializeObject(data)
printfn "Serialized: %s" json

let deserialized = JsonConvert.DeserializeObject(json)
printfn "Deserialized: %A" deserialized

Execute with: dotnet fsi json-example.fsx

Performance Profiling

You can measure execution time using #time directive in scripts:

// performance-test.fsx
#time "on"

let slowFunction () = 
    [1..1000000] |> List.sum

printfn "Testing performance..."
let result = slowFunction()
printfn "Sum result: %d" result

Execute with: dotnet fsi performance-test.fsx

Running this script shows timing information for each operation.

Script-like Development

FSI bridges the gap between scripting and compiled languages. You can write substantial programs that feel like scripts but have the performance and type safety of compiled F#:

// web-download.fsx
open System.Net.Http
open System.Threading.Tasks

let downloadAndProcess (url: string) =
    task {
        use client = new HttpClient()
        let! content = client.GetStringAsync(url)
        return content.Length
    }

let urls = [
    "https://api.github.com/users/octocat"
    "https://httpbin.org/json"
]

printfn "Downloading content lengths..."
for url in urls do
    try
        let length = downloadAndProcess url |> Async.AwaitTask |> Async.RunSynchronously
        printfn "URL: %s, Content Length: %d" url length
    with
    | ex -> printfn "Error downloading %s: %s" url ex.Message

Execute with: dotnet fsi web-download.fsx

Type-Driven Development

FSI excels at type-driven development in script files. You can define types first and let the compiler guide implementation:

// type-driven.fsx
type Customer = { Id: int; Name: string; Email: string }
type Order = { Id: int; CustomerId: int; Items: string list; Total: decimal }

let findCustomerOrders (customers: Customer list) (orders: Order list) customerId =
    orders |> List.filter (fun o -> o.CustomerId = customerId)

// Test data
let customers = [
    { Id = 1; Name = "John Doe"; Email = "john@example.com" }
    { Id = 2; Name = "Jane Smith"; Email = "jane@example.com" }
]

let orders = [
    { Id = 101; CustomerId = 1; Items = ["laptop"; "mouse"]; Total = 1200M }
    { Id = 102; CustomerId = 2; Items = ["keyboard"]; Total = 100M }
    { Id = 103; CustomerId = 1; Items = ["monitor"]; Total = 300M }
]

// Test the function
let johnOrders = findCustomerOrders customers orders 1
printfn "John's orders: %A" johnOrders
let totalValue = johnOrders |> List.sumBy (fun o -> o.Total)
printfn "Total value: %M" totalValue

Execute with: dotnet fsi type-driven.fsx

The type signatures provide immediate feedback about function behavior and help catch errors early when you run the script.

Working with Existing Projects

When working with existing F# projects, I create script files to explore and test without affecting the main codebase:

// project-exploration.fsx
// Reference the compiled project
#r "bin/Debug/net8.0/MyProject.dll"
open MyProject.Core
open MyProject.Models

// Test existing functions with new data
let testUser = { Name = "Test User"; Email = "test@example.com" }
let validationResult = UserValidation.validate testUser
printfn "Validation result: %A" validationResult

// Experiment with modifications
let improvedValidation user =
    // Test enhanced validation logic here
    UserValidation.validate user
    // Additional checks...

let enhancedResult = improvedValidation testUser
printfn "Enhanced validation: %A" enhancedResult

Execute with: dotnet fsi project-exploration.fsx

Limitations and Considerations

FSI isn’t perfect for all scenarios. Large applications with complex build processes, extensive configuration, or performance-critical code may require traditional compilation.

However, for exploration, prototyping, data analysis, and incremental development, FSI provides an unmatched development experience. Also, even with large code bases, you can opt to use parts of the code base in the FSI.

Script files also have some limitations:

  • No IntelliSense in basic text editors (though many IDEs support .fsx files)
  • Debugging is more limited compared to compiled projects, however, who needs debugging when writing F#
  • Large scripts can become unwieldy without proper organization. You can move everything in modules, however, effectively creating an organized script file!

Conclusion

F# Interactive has transformed my development workflow from a traditional batch process to an interactive conversation with code through script files. The ability to experiment freely, shadow modules safely, and build incrementally with immediate feedback creates a development experience that’s both more productive and more enjoyable. While I eventually move tested code to proper source files, the path to that final code is far more direct and confident through FSI’s script-based interactive environment.

The key is treating script files as throwaway exploration tools – write them quickly, test ideas immediately, and don’t worry about perfect code structure. Once you’ve proven your approach works, then refactor and move to proper source files. This workflow has dramatically improved my productivity and code quality.

Bottom Line: USE THE FSI

Leave a Reply

Your email address will not be published. Required fields are marked *