Blog › ICP guides

F# developer on retainer: computation expressions, discriminated unions, type providers, and .NET functional programming on monthly retainer

October 5, 2026 · ~20 min read

A fintech company’s event processing microservice, written in F# on .NET 8, was experiencing memory growth of approximately 500MB per hour under sustained load of 1,000 events per second. The operations team had added memory to the production host twice in three months. A profiling session with dotMemory identified the retention source as a single Seq.cache call in the hot path. The pipeline was structured as events |> Seq.map transform |> Seq.filter isValid |> Seq.cache |> Seq.toList — and Seq.cache materializes a lazy sequence and holds all evaluated elements in memory indefinitely. Under continuous event flow, the cached sequence accumulated the entire processing history for the lifetime of the server process. A second enumeration point in a downstream consumer was also triggering the underlying sequence to restart from source, causing double processing of every event. The F# specialist on retainer identified both issues: replaced the hot path with Array.map transform |> Array.filter isValid (strict, bounded, single-pass, no accumulation), removed Seq.cache entirely, and switched the unbounded stream consumer to IAsyncEnumerable<T> via taskSeq { for event in stream do yield! processAsync event }. Memory growth: 500MB per hour → under 5MB per hour over a 48-hour test run.

The work log entry for this engagement read “replaced Seq pipeline with Array pipeline in event processor hot path, removed Seq.cache, 4.5h.” It describes the duration and leaves the client unable to explain to their infrastructure team why changing four instances of Seq to Array in the pipeline eliminated three months of memory growth on the production host. The diagnosis required understanding F#’s lazy sequence model, recognizing that Seq.cache’s semantics differ entirely from a caching layer in other frameworks, and knowing that the second enumeration point was the mechanism driving double processing. None of that understanding has an artifact in the committed diff.

F# fundamentals: let bindings, discriminated unions, records, pipe operator, and pattern matching

F# uses let for all bindings: let x = 5 binds an immutable value; let f x = x + 1 defines a function; let mutable y = 0 introduces a mutable variable (rare in idiomatic F#). Functions are curried by default: let add a b = a + b is a two-argument function that can be partially applied as let addFive = add 5. Type inference eliminates most annotations, but explicit types at module boundaries improve readability: let process (event : Event) : Result<Output, string> = .... The pipe operator |> feeds the left-hand value into the right-hand function: events |> Array.filter isValid |> Array.map transform |> Array.toList. Function composition >> creates a new function: let processEvent = validate >> transform >> persist composes three functions into one. The backward pipe <| reduces parentheses: printfn "%A" <| computeResult arg1 arg2. These operators are pervasive in F# codebases and their absence in a retainer work log entry — “added pipe operator, 2h” — communicates almost nothing about what the hours produced.

Discriminated unions are F#’s most powerful type construct. type Shape = Circle of float | Rectangle of float * float | Triangle of float * float * float defines a type with three cases; the compiler verifies every match expression covers all cases. Single-case discriminated unions enforce type safety without runtime cost: type UserId = UserId of string and type ClientId = ClientId of string are structurally identical but incompatible at the type level — passing a ClientId where a UserId is required is a compile-time error. A retainer engagement that replaces 8 stringly-typed record fields with single-case DUs produces a commit that catches 6 call-site bugs at compile time; the work log entry “replaced string fields with single-case DUs, 12h” captures neither the audit of all 94 call sites nor the design decisions about which fields warranted wrapping. Record types provide structural equality and copy-update syntax: type Config = { Host : string; Port : int; Timeout : int } allows let updated = { config with Timeout = 30 }. The [<Struct>] attribute converts a record to a value type for performance in hot paths. Records and DUs together cover most domain modeling requirements without object hierarchies.

Pattern matching in F# is exhaustive and expressive. match shape with | Circle r → Math.PI * r * r | Rectangle (w, h) → w * h | Triangle (a, b, c) → ... is verified complete at compile time; adding a new DU case forces every match expression to be updated or the compiler warns. Active patterns extend matching to computed conditions: let (|Positive|Negative|Zero|) n = if n > 0 then Positive elif n < 0 then Negative else Zero creates a custom pattern usable in any match. Guard conditions add runtime predicates: match event with | Payment p when p.Amount > 10_000m → flagLarge p | Payment p → processNormal p | _ → ignore(). The Option type (Some value / None) replaces null: Option.map, Option.bind, Option.defaultValue chain transformations safely. The Result type (Ok value / Error err) carries errors without exceptions: Result.map, Result.bind, Result.mapError form a railway-oriented programming pipeline where the happy path and error path are encoded in the type and enforced by the compiler. A retainer engagement restructuring error handling from exception-based to Result-based typically involves 30 to 80 call sites and produces no visible behavior change — the failures just become typed rather than caught.

F#’s collection types have meaningfully different performance characteristics that become important in hot paths. List is an immutable singly-linked list: O(1) prepend (x :: list), O(n) append, O(n) random access, no allocation for pattern matching. Array is a mutable contiguous block: O(1) random access, O(n) prepend (allocates), O(n) copy on functional update. Seq is a lazy IEnumerable<T>: elements are computed on demand, no storage if not cached, but Seq.cache forces evaluation and stores all results. Map is an immutable balanced binary tree: O(log n) lookup and update, structural sharing on update. Set is the same for membership. For a bounded batch of known cardinality, Array is typically the correct choice — single allocation, cache-friendly layout, no iterator overhead. For an unbounded stream, IAsyncEnumerable<T> via taskSeq or an F# Mailbox­Processor message queue is appropriate. The decision between these types is an architectural choice with measurable performance consequences; a retainer engagement optimizing a data pipeline commonly involves profiling to identify which collection type is allocating, then selecting the right one for each pipeline stage.

Computation expressions, async/task, type providers, Fable, and .NET interoperability

Computation expressions (CEs) are F#’s mechanism for domain-specific languages embedded in the language. The async { ... } CE represents F#’s native asynchronous model: let fetchData url = async { let! response = httpClient.GetAsync(url) |> Async.AwaitTask; let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask; return content }. let! binds an asynchronous value (awaiting it); do! executes an async unit operation; return wraps a value in the async context. Async.RunSynchronously blocks the calling thread; Async.StartAsTask converts to a .NET Task<T>. The critical difference from C#’s async/await: F# Async is cold (not started until explicitly started) and uses a cooperative cancellation model based on Async.CancellationToken. When Async.Start or Async.StartAsTask is called without passing a CancellationToken, the async workflow cannot be cancelled — it runs to completion even after the caller has moved on. A retainer engagement finding 12 async workflows started without cancellation tokens, each accumulating intermediate state across abandoned processing pipelines, typically spends 8 to 20 hours threading CancellationToken parameters through the call graph and replacing Async.StartAsTask with Async.StartWithContinuations or migrating to task { }.

The task { ... } computation expression (available via open System.Threading.Tasks in F# 6+ or via FSharp.Control.Tasks NuGet) interoperates directly with .NET Task<T>: let! result = someTask awaits a Task<'T> without the Async.AwaitTask adapter that async requires. The task { } CE is hot (starts immediately on creation), matches C#’s Task semantics, and accepts CancellationToken natively via let! ct = Async.CancellationToken or the use! ct = Async.CancellationToken pattern. For F# code that primarily interoperates with .NET libraries (HttpClient, Entity Framework, SignalR, Azure SDK), task { } eliminates the impedance mismatch of converting between F# Async<'T> and .NET Task<'T>. Custom computation expression builders enable domain-specific workflows: a validation { } CE can accumulate multiple errors rather than short-circuiting on the first; a retry { } CE can transparently handle transient failures with backoff; AsyncResult from FSToolkit provides a CE for Async<Result<'T, 'E>> that binds both layers simultaneously. A retainer engagement designing a custom CE builder for a domain workflow typically invests 10 to 25 hours in the builder object design (implementing Bind, Return, ReturnFrom, Zero, Combine, Delay, Run methods) before the consumer code can use natural CE syntax.

Type providers are one of F#’s most distinctive features: they generate types at compile time from external schemas, eliminating hand-written data access code and schema drift. FSharp.Data.JsonProvider<"schema.json"> generates a complete type hierarchy from a sample JSON document — Root.Parse(json).Events |> Array.map (fun e → e.Timestamp) is type-safe against the JSON schema, with IntelliSense showing all fields. FSharp.Data.CsvProvider<"data.csv"> generates column types from CSV headers, with optional inference of numeric/date types. SqlHydra or SQLProvider generate types from a live database schema at compile time, so renaming a column in the database produces a compile-time error in F# code rather than a runtime KeyNotFoundException. Type provider setup — configuring connection strings, schema sample files, inference parameters, and compile-time vs runtime data source switching — commonly takes 4 to 12 hours per provider integration; the code produced is often a single type MyData = JsonProvider<"sample.json"> line that conceals those hours entirely. A retainer engagement integrating a type provider for a poorly documented external API can involve multiple iteration cycles to find a sample document representative enough to generate useful types.

Fable compiles F# to JavaScript (and TypeScript), enabling full-stack F# development where the same domain types, validation logic, and DU-based state machines are shared between server and client. The Elmish library brings the Elm Model-View-Update architecture to Fable: a discriminated union Msg type enumerates all possible user actions; an update : Model → Msg → Model * Cmd<Msg> function handles each case exhaustively; a view : Model → dispatch:(Msg → unit) → ReactElement renders the current state. The compiler verifies that every Msg case is handled in update — adding a new UI action requires updating the match. FAKE (F# Make) provides a build system using .fsx scripts: Target.create "Build" (fun _ → DotNet.build id "MyProject.fsproj") defines targets; Target.runOrDefault "Build" executes them. A retainer engagement maintaining a Fable/Elmish application’s update function as new features are added commonly involves reviewing the DU for consistency, identifying dead branches, and restructuring nested match expressions as model complexity grows — work that is invisible as a shipped artifact but critical for long-term maintainability.

How HourTab tracks F# developer retainer hours

F# retainer work produces a version of the invisible-work problem where the most critical engineering decisions — choosing Array vs Seq for a pipeline stage, threading CancellationToken through an async call graph, designing a DU hierarchy that the compiler can verify exhaustively, or configuring a type provider to generate types from a schema — produce diffs that look trivially small. Replacing Seq.map with Array.map in four pipeline expressions is eight characters changed. The diagnostic work — attaching dotMemory to a production-load test, capturing the heap snapshot, finding Seq.cache holding 12 million event records, understanding why Seq.cache semantics differ from other caching layers, and verifying that the second enumeration point was triggering double processing — consumed four hours and produced none of those eight changed characters. The work log entry “replaced Seq with Array pipeline, 4.5h” leaves the client unable to brief their infrastructure team on why three months of memory growth was eliminated by a source diff that touches four lines.

HourTab gives F# developers a public retainer-hours URL they send to clients — typically .NET platform teams, financial services companies, or data-intensive businesses — at the start of an engagement. The client bookmarks the URL and checks hours remaining without emailing the developer. The work log is where the technical context lives. For F# retainers, each entry should name the mechanism involved (Seq lazy evaluation memory accumulation; Array migration for bounded batch pipelines; IAsyncEnumerable via taskSeq for unbounded streams; async CancellationToken propagation; task { } CE migration for direct .NET Task integration; custom CE builder design; discriminated union domain modeling; single-case DU type-safe wrapper; active pattern authorship; Result/Option railway-oriented programming; FSharp.Data type provider integration; FAKE build script authorship; Fable compilation target; Azure Functions entry point; BenchmarkDotNet profile; dotMemory heap snapshot analysis), the specific collection type, CE, or pipeline stage, the diagnostic tool and output (dotMemory heap snapshot showing Seq.cache holding 12M event records at 487MB; BenchmarkDotNet showing Seq.map pipeline allocating 3.2MB per invocation vs Array.map at 0 allocated; compiler DU exhaustiveness error showing new Msg case unhandled in update; type provider compile-time error on renamed database column), the change applied and why, and the before/after observable metric. Entries at that specificity turn an invoice line into a documented reliability improvement the .NET platform team can reference in their post-incident review. F# retainers are often compared to Haskell developer retainers for type-driven design and Scala developer retainers for JVM/CLR functional programming, both of which share the invisible-work challenge of architectural decisions whose value is in what they prevent rather than what they produce.

Track F# developer retainer hours without the status emails

HourTab gives F# developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: F# developer retainers

What does an F# developer on retainer typically do?

An F# developer on monthly retainer provides pipeline performance and memory management (Seq lazy evaluation accumulation diagnosis; Array/List migration for bounded batches; IAsyncEnumerable taskSeq for unbounded stream consumers; dotMemory/BenchmarkDotNet profiling), computation expression design (async CancellationToken propagation restructuring; task CE migration for direct .NET Task integration; custom CE builder design for retry/validation workflows; FSharpPlus/FSToolkit AsyncResult railway-oriented error handling), type system design (discriminated union domain modeling; single-case DU type-safe wrappers; active pattern authorship; Result/Option railway-oriented programming; type provider integration for SQL/CSV/JSON schema-driven types), and .NET/ecosystem integration (C# library interop with F# attributes; FAKE build script design; Azure Functions F# entry points; Fable F#-to-JavaScript compilation; Ionide tooling configuration).

What F# work is most underlogged in a retainer?

Seq lazy evaluation memory diagnosis (Seq.cache materializing unbounded stream history — events |> Seq.map ... |> Seq.cache |> Seq.toList at 1,000 events/sec accumulated 500MB/hr; replaced with Array.map for bounded batch + taskSeq for unbounded stream; memory growth: 500MB/hr → under 5MB/hr; 6–16 hrs invisible in 4-line type name change), async CancellationToken propagation restructuring (12 async workflows started without CancellationToken leaving tasks running after caller cancellation; ~200 abandoned tasks per cancellation event; restructured with Async.StartWithContinuations + CancellationToken threading through call graph; abandoned tasks: ~200 → 0; 8–20 hrs invisible in parameter threading), and discriminated union domain modeling (8 stringly-typed record fields replaced with single-case DUs; compiler caught 6 call sites passing ClientId where UserId required; bugs caught at compile time rather than production; 8–18 hrs invisible in DU definitions and match updates).

What are typical F# developer retainer rates?

Entry-level F# developers (1–2 years, let/fun bindings, DU/record types, pattern matching, pipe operator, List/Array/Seq operations, Option/Result, async CE) bill at $80–$140/hr. Mid-level F# engineers (2–4 years, task CE migration and CancellationToken propagation, type provider integration, railway-oriented programming with AsyncResult, FAKE build system, Seq vs Array performance trade-offs, custom active pattern authorship) bill at $130–$235/hr. Senior F# architects (4–8 years, full domain model design with exhaustive DU verification, Fable F#-to-JavaScript full-stack, Azure Functions architecture, performance optimization with BenchmarkDotNet and dotMemory, .NET interop with []/[] attributes) bill at $185–$330/hr. Monthly retainer ranges: $2,500–$5,500/mo for advisory retainers (15–25 hrs), $7,000–$20,000/mo for full F# platform development engagements.

What should an F# developer retainer agreement include?

An F# developer retainer agreement should specify: pipeline performance scope (Seq lazy evaluation diagnosis and Array/List migration; IAsyncEnumerable via taskSeq; dotMemory/BenchmarkDotNet profiling; intermediate collection allocation auditing), computation expression scope (async CancellationToken propagation; task CE migration; custom CE builder design; FSharpPlus/FSToolkit AsyncResult integration), type system scope (DU domain modeling; single-case DU wrappers; active pattern authorship; Result/Option railway-oriented programming; type provider integration for SQL/CSV/JSON), .NET integration scope (C# library interop; FAKE build scripts; Azure Functions; Fable compilation targets; Ionide configuration), and hour logging format (pipeline stage and collection type, memory profile before/after, async workflow restructuring with CancellationToken propagation, DU modeling with compiler error count, before/after observable metric).

How should F# developer retainer hours be logged?

Log each F# retainer session with: advisory category (Seq lazy evaluation memory diagnosis; Array/List migration for bounded pipelines; IAsyncEnumerable taskSeq for unbounded streams; async CancellationToken propagation; task CE migration; custom CE builder design; DU domain modeling; single-case DU type-safe wrapper; active pattern authorship; Result/Option railway-oriented programming; FSharp.Data type provider integration; FAKE build script authorship; Fable compilation target; Azure Functions entry point; BenchmarkDotNet profile; dotMemory heap snapshot), the specific pipeline stage or collection type, diagnostic tool and output (dotMemory snapshot showing Seq.cache holding 12M event records at 487MB; BenchmarkDotNet showing Seq.map allocating 3.2MB/call vs Array.map at 0 allocated; DU exhaustiveness error on new Msg case; type provider compile-time error on renamed column), change and why (Seq.cache holds all evaluated elements indefinitely — inappropriate for unbounded streams; Array for bounded batches; CancellationToken required to allow async workflow cancellation), and before/after metric (memory growth: 500MB/hr → <5MB/hr; abandoned tasks per cancel: ~200 → 0; DU compiler errors caught before production: 6). Include .NET version, F# version, FSharp.Core version, and relevant NuGet packages.