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:
- Create an
.fsx
script file for experimentation - Load relevant modules and experiment with data structures
- Build functions incrementally, testing each piece
- Refactor and optimize in the script environment
- 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