Tagless Final
The Tagless Final pattern (sometimes called Finally Tagless) is a well-known approach in the functional programming community for defining domain-specific languages (DSLs). It replaces the free monad's data structure encoding with a direct encoding using interfaces (type classes in Haskell). This is version 4 of our program, and it represents a radical simplification over V3.
The Core Idea
In the initial (or tagged) encoding (V2 and V3), we represent programs as data structures — discriminated unions, instruction classes — that are later interpreted. In the final (or tagless) encoding, we represent programs as functions parameterized by an interface (the "algebra") that describes the available operations.
Program type
ADT: Stop | Effect of ...
Function: 'ins -> Async<'ret>
Instructions
Union cases + Effect classes
Interface methods
Interpretation
Recursive pattern match
Direct invocation of interface implementation
Adding operations
Add union case, effect class, helper
Add interface method
Type parameters
Program<'ret>
Program<'ins, 'ret>
In Haskell, this is achieved with type classes; in F#, we use interfaces — which serve the same role of abstracting the algebra of operations.
Why Move from V3 to V4?
The Variance Problem
V3's IProgramEffect<'a> interface required a Map method returning IProgramEffect<'b>:
// V3 — problematic
[<Interface>]
type IProgramEffect<'a> =
abstract member Map: f: ('a -> 'b) -> IProgramEffect<'b>This design made the type a functor — sufficient for sequential let! ... let! composition. However, to support parallel execution via the applicative syntax let! ... and! ..., we need a map2 function:
This is effectively impossible in F# because:
The
Mapmethod's return typeIProgramEffect<'b>is covariant in'b, but the effect implementations wrapping domain-specific instruction types are invariant.F#'s .NET generics enforce strict variance checking — unlike Haskell's type classes, which are structurally flexible.
Implementing
MergeSources(the CE method backingand!) onProgram<'ret> = Stop of 'ret | Effect of IProgramEffect<Program<'ret>>would require composing two effects that know nothing about each other.
This made parallel instruction execution essentially impossible with the V3 architecture.
The Boilerplate Problem
V3 required a 5-step recipe per instruction:
Type alias (
GetPricesQuery<'a>)Union case (
GetPrices of GetPricesQuery<'a>)Effect interface (
IProductEffect<'a>)Effect class (4 lines each:
GetPricesEffect<'a>)Helper function (
let getPrices = Program.effect GetPricesEffect GetPricesQuery)
For 5 instructions, that's ~50 lines of boilerplate before writing any workflow logic.
V4: The Tagless Final Solution
Program = Async Reader
The entire Program type collapses to a single type alias:
A program is simply a function that:
Takes
'ins— an instruction set (an interface inheritingIProgramInstructions)Returns
Async<'ret>— an asynchronous result
This is the ReaderT monad pattern: the "environment" being read is the set of instructions.
Instructions = Interface Methods
Each domain defines its instructions as a plain interface:
Each member's type is a function 'arg -> Async<'ret>, making each member essentially a single instruction. This is the Tagless Final algebra — no union type, no effect class, just interface methods.
Defining Programs from Instructions
A helper type makes defining programs from instructions ergonomic:
Under the hood, DefineProgram.instruction is just the identity function — it's a DevExp aid that triggers IntelliSense on the instruction interface, using F#'s shorthand lambda syntax (_.Method(args)).
Benefits
Simplicity
1 type alias instead of the free monad ADT
1 interface instead of union + effect classes + aliases
1 line per instruction in the helper module
No interpreter loop — the CE builder directly composes async functions
Monadic Bind Without Functor
The bind function — essential for the program CE's let! syntax — illustrates why V4 is fundamentally simpler.
V3 relied on Program<'ret> being an ADT (Stop | Effect). Its bind had to recursively traverse this tree, calling Map on the effect at each node:
The effect.Map(bind f) call is what forced every instruction type to implement IProgramEffect<'a>.Map — i.e., to be a functor. This was the root cause of the variance problem described above: Map had to return IProgramEffect<'b>, which imposed covariance constraints incompatible with F#'s strict generic variance checking.
V4 replaces the ADT with a function 'ins -> Async<'ret>. Binding two programs is just function composition, with sequencing delegated to Async's own bind (let!):
The instructions ('ins) are simply threaded through as a reader environment — no Map, no recursion, no functor constraint. The monadic plumbing is entirely handled by the Async computation expression.
Parallel Execution
Since Program<'ins, 'ret> is just 'ins -> Async<'ret>, implementing map2 is trivial:
This enables the applicative let! ... and! ... syntax in the program CE:
Both instructions run concurrently — something impossible in V3.
Extensibility
The reader-based design naturally supports:
Undo / Saga pattern: The instruction preparer wraps each instruction with tracking and undo capabilities, without modifying the program type.
Observability: Logging, metrics, and timing are injected at the instruction preparation level.
Testing: Mock the instruction interface — no interpreter to stub.
Complementary Resource
Series Tagless Final in F# by John Azariah 🐸
What's Next
The following sections detail the Program project implementation, then show how domain workflows use this pattern in practice.
Last updated