Bind Chains in VB.NET - Maybe, Result, and Pipeline OperatorsPart 2 of a three-part series on functional programming in VB.NET. Monads, railroad programming, and a small library that grew out of one WinForms codebase. |
|||
| Version | v1792077373 | ||
|---|---|---|---|
| Updated | |||
Part 2 of a three-part series on functional programming in
VB.NET. Part 1 covered the patterns built into the language. These notes
cover monads (Maybe, Result, and bind chains
for railroad programming). Part 3 covers domain modeling with records
and type classes.
last updated: 2026-07-20 · part 2 of 3
Part 1 covered what VB.NET already gives you: LINQ, extension methods, lambdas, generic constraints, iterator functions. With those alone I can write most of my code in a functional style.
These notes are about what to add when those are not enough. When a sequence of operations can fail at every step. When “this might be null” is a load-bearing concept, not just a code smell. When I want the compiler to enforce short-circuiting on error.
That is monad territory. And because VB.NET does not have them built in, I had to build a small library.
I work in a WinForms VB.NET application for Westport Yachts. The task builder UI has functions that take a routing key, fetch a routing, modify it, and commit. Most of them have to handle: the routing might not exist, the commit might fail, the cache needs to be invalidated, and the result needs to be wrapped as a tree node for the UI.
In imperative VB.NET, that is a Try block with three
If x Is Nothing checks, an If result.IsFailure
check, a cache-invalidation call between commit and return, and an
exception handler at the bottom. It works. It also buries the happy path
under error-handling scaffolding, and every new step risks a new
If or a moved line.
The bind chain reads top to bottom, every step is one line, and the type system enforces short-circuiting on failure. Once I had a working version, I stopped writing the imperative form.
The rest of these notes are the library that makes that work, and the patterns I have found useful inside it.
Do not build a discriminated union in VB.NET from scratch.
VB.NET has no native discriminated union (DU). The standard advice
online is to implement your own OneOfBase(Of T1, T2), a
class with two fields, type checks, and a Match function
that takes two lambdas. That is two hundred lines I would have to write,
maintain, and explain.
Instead, use OneOf. It
is a NuGet package that gives you discriminated unions with up to eight
cases out of the box. The runtime does the type-checking. The package
gives you IsT0, IsT1, AsT0,
AsT1, and a Match function that takes one
lambda per case.
Here is the entire implementation of Result(Of T, F) in
our codebase:
Imports OneOf
Imports OneOf.Types
Namespace Monad
Public Class Result(Of T, F)
Inherits OneOfBase(Of T, F)
Protected Sub New(input As OneOf(Of T, F))
MyBase.New(input)
End Sub
Public Shared Function Fail(e As F) As Result(Of T, F)
Return New Result(Of T, F)(e)
End Function
Public Shared Function Success(v As T) As Result(Of T, F)
Return New Result(Of T, F)(v)
End Function
Public Function IsSuccess() As Boolean
Return Me.IsT0
End Function
Public Function IsFailure() As Boolean
Return Me.IsT1
End Function
Public Function Map(Of R)(fun As Func(Of T, R)) As Result(Of R, F)
Return Me.Match(
Function(successVal) Result(Of R, F).Success(fun(successVal)),
Function(e) Result(Of R, F).Fail(e))
End Function
Public Function Bind(Of R)(fun As Func(Of T, Result(Of R, F))) As Result(Of R, F)
Return Me.Match(
Function(successVal) fun(successVal),
Function(e) Result(Of R, F).Fail(e))
End Function
End Class
End Namespace
The whole thing fits on a screen. Bind and
Map are short-circuit by construction. When
Match picks the failure branch, the success continuation
never runs. Railroad semantics for free.
Maybe(Of T) looks the same: wrap
OneOf(Of T, None) and add
Some/None factories.
We also have a third wrapper, ApiResult(Of T), for the
I/O-heavy parts of the system. The difference is refinements that real
production code needs:
' Wrap any throwing code into an ApiResult
Public Shared Function DontPanic(Of TOut)(fun As Func(Of TOut)) As ApiResult(Of TOut)
Try
Return ApiResult(Of TOut).Success(fun())
Catch ex As ApiError
Return ApiResult(Of TOut).Fail(ex)
End Try
End Function
' Branch on a predicate without leaving the pipeline
Public Function BindIf(Of R)(
test As Func(Of T, Boolean),
funA As Func(Of T, ApiResult(Of R)),
funB As Func(Of T, ApiResult(Of R))
) As ApiResult(Of R)
Return Me.Match(
Function(successVal) If(test(successVal), funA(successVal), funB(successVal)),
Function(e) ApiResult(Of R).Fail(e))
End Function
The DontPanic and BindIf additions
are what made the difference between “we have a monad library”
and “we actually use this everywhere.” I will come back to
BindIf. It is the thing F# pattern matching does in one
line, that we had to invent a primitive for in VB.NET.
The three-tier design is deliberate:
| Type | Use when | DB-null safe? | Has BindIf? |
|---|---|---|---|
Maybe(Of T) |
Pure domain logic, nullable values | Yes (via FromDBNull) |
No |
Result(Of T, F) |
Domain logic with a typed error | No | No |
ApiResult(Of T) |
API calls, I/O, anything that throws | No | Yes |
Pick the lightest one that does the job. Maybe is the
default for “this might be null.” Result is for “this might
fail with a domain error.” ApiResult is for “this might
throw, fail at the network layer, or return malformed data.”
Here is the function that modifies a routing operation in our task builder. I can read it top to bottom without knowing any of the underlying types.
Private Function ModifyOperation(rtgKey As ItemMaster.FullPartKey,
op As StandardRouting.Operation) As ApiResult(Of Node)
WriteToEventLog($"Modifying Operation {op.OperationNumber} in {rtgKey}", ...)
Return rtgKey _
.Map(AddressOf Me.GetSourceFacPartKey) _ ' 1. resolve to facility part key
.Map(Me.SafeGetRtg) _ ' 2. fetch the routing
.Map(Me.UpdateRtg) _ ' 3. open it for editing
.Map(Function(rtg) rtg.ModifyOperation(op)) _ ' 4. apply the modification
.Bind(Me.SafeCommitRtgChanges) _ ' 5. commit (could fail)
.Tee(Sub(result) If result.IsSuccess Then RemoveFrom(Me.RtgCache, rtgKey)) _ ' 6. side effect: cache invalidation
.Bind(Function(result) ApiResult(Of Node) _
.Success(Me.NodeFrom(op))) ' 7. wrap result as a tree Node
End Function
Seven steps. Each is one line. The pipeline:
ApiResult(Of Node) that either contains the
updated node or a description of what went wrong.If any step fails (SafeGetRtg cannot find the routing,
SafeCommitRtgChanges rejects the update), every step after
it is skipped. The failure propagates to the end. There is no
If x Is Nothing Then ... Else ... pyramid. There is no
Try ... Catch swallowing exceptions. There is just the
chain.
Compare to the imperative version that the same logic would have in most VB.NET codebases:
Private Function ModifyOperation(rtgKey As ItemMaster.FullPartKey,
op As StandardRouting.Operation) As ApiResult(Of Node)
WriteToEventLog($"Modifying Operation {op.OperationNumber} in {rtgKey}", ...)
Try
Dim facKey = Me.GetSourceFacPartKey(rtgKey)
Dim rtg = Me.SafeGetRtg(facKey)
If rtg Is Nothing Then Return ApiResult(Of Node).Fail(New ApiError("Routing not found"))
Dim updatedRtg = Me.UpdateRtg(rtg)
Dim modifiedRtg = updatedRtg.ModifyOperation(op)
Dim committed = Me.SafeCommitRtgChanges(modifiedRtg)
If committed.IsFailure Then Return ApiResult(Of Node).Fail(committed.FailureValue)
RemoveFrom(Me.RtgCache, rtgKey)
Return ApiResult(Of Node).Success(Me.NodeFrom(op))
Catch ex As Exception
Return ApiResult(Of Node).Fail(New ApiError(ex.Message, ex))
End Try
End Function
Half the line count, but reads worse:
Try block.If checks for null/failure scattered
through the function.If or moving things around.With bind, every step is one line. Adding a step is one line. The type system enforces short-circuiting on failure.
The pipeline shape stays the same when you change which functions
run. Look at the .Map calls. Each one takes a method
reference. That means:
' Today:
.Map(Me.UpdateRtg)
.Map(Function(rtg) rtg.ModifyOperation(op))
' Tomorrow, if UpdateRtg changes:
.Map(Me.NewUpdateRtg) ' drop-in replacement
.Map(Function(rtg) rtg.ModifyOperation(op)) ' unchanged
' Or for a different operation entirely:
.Map(Me.UpdateRtg)
.Map(Function(rtg) rtg.DeleteOperation(op)) ' just swap the modifier
The pipeline is the same shape regardless of which functions run. I can:
.Tee (cache
invalidation) before .Bind to invalidate before committing,
which is useful for testing..Tee(logAudit)
between any two lines without restructuring.This is what “composable” means in practice. The bind chain is the assembly line. The methods are the interchangeable stations.
Tee mattersMap and Bind change the value flowing
through the pipeline. Tee does not. It runs a side effect
and returns the original value unchanged. This sounds trivial. It is
not.
Private Function RemovePart(thisPart As Part) As ApiResult(Of String)
WriteToEventLog(...)
If thisPart.Value.Effectivity.Equals(StandardBom.Effectivity.Current) Then
Dim bomItemNumber As Integer,
parseResult As Boolean = Integer.TryParse(thisPart.Value.BOMItemNumber, bomItemNumber)
Dim yesterday = DateAdd(DateInterval.Day, -1, Date.Now)
thisPart.Value.EffectiveEndDate = yesterday
Return thisPart.Value _
.PPartKey _
.Map(Me.SafeGetBOM) _
.Map(Me.UpdateBOM) _
.Map(Function(bom) bom.ModifyComponent(thisPart.Value)) _
.Bind(Me.SafeCommitBOMChanges) _ ' ← could fail
.Tee(Sub() If parseResult Then Me.Linked.Remove(ProductSctrutureID.From(bomItemNumber))) _
.Bind(Function(id) ApiResult(Of String) _
.Success($"Success requested to remove {thisPart.Value.CPartKey} from {id}"))
Else
Return Me.RemovePartFinalize(thisPart)
End If
End Function
The .Tee step removes a linked item from another data
structure, but only if parsing succeeded, and
without breaking the pipeline. The alternatives I had
considered before adding it:
If result.IsSuccess Then block (and risk forgetting it on a
new code path), orTee keeps the pipeline linear. The side effect runs at
exactly the right point. The value keeps flowing.
This is the single most useful extension we added.
Every bind chain in the codebase has at least one Tee.
The hardest thing to fake in VB.NET is F#-style pattern matching. I cannot write:
match result with
| Ok bom -> ...
| Error (NotFound id) -> ...
| Error (ValidationError msg) -> ...What we can do is Match against the OneOf
cases:
Return result.Match(
Function(success)
Dim taskComponent = New StandardBom.Component(...)
Return ApiResult(Of Node).Success(Me.NodeFrom(taskComponent))
End Function,
Function(fail) ApiResult(Of Node).Fail(fail))
That works for two-way unions. For more complex branching (“if the
BOM has only one component, delete the whole thing; otherwise delete
just the component”) I wanted a primitive for it. So I added
BindIf:
Private Function RemoveTaskFinalize(thisTask As Task) As ApiResult(Of String)
WriteToEventLog(...)
Dim removeJustTheComponent As Func(Of StandardBom.Record, ApiResult(Of String)) =
Function(b)
Return b.Map(Me.UpdateBOM) _
.Map(Function(ub) ub.DeleteComponent(thisTask.Value)) _
.Map(Me.SafeCommitBOMChanges) _
.Bind(Function(id) ApiResult(Of String) _
.Success($"Success removed {thisTask.Value.CPartKey} from {id}"))
End Function
Return thisTask.Value.PPartKey _
.Map(Me.SafeGetBOM) _
.BindIf(AddressOf hasOnlyOneComponent,
AddressOf RemoveTheWholeBOM,
removeJustTheComponent)
End Function
BindIf(test, funA, funB) keeps the branch inline. The
pipeline stays a single expression. The branch decision is right where
the logic lives.
If hasOnlyOneComponent(bom) is true, delete the whole
BOM. Otherwise, delete just the component. No If/Else
block. No early return. No temporary variable. The pipeline stays
intact.
This is the right kind of pragmatism: do not fight the language, but extend it where the language falls short.
Some things I have tried and abandoned:
Point-free style.
Function(x) Function(y) x + y chains that strip out
variable names. VB.NET’s verbosity fights you. The bind chain is
point-free enough. Do not push further.
Currying and partial application. Without language
support, you end up wrapping Func of Func of
Func. The ceremony outweighs the benefit. If I find myself
wanting this, the answer is F#, not better VB.NET.
Custom monads for everything. Most of my code is
“might be null” or “might fail.” Maybe and
Result cover it. I do not invent a State monad
or a Reader monad in VB.NET. The language will punish
me.
Operator overloading for control flow. Yes, you can
define Or to throw exceptions. I tried it once. The
die(...) Perl-style operator looked great in isolation and
confused everyone who had to maintain the code.
Pure FP purity for its own sake. I use
For loops where loops are clearer. I use mutation where
mutation is clearer. The bind chain is a tool for orchestration, not a
religion.
Anything that requires compiler-enforced exhaustiveness. F# can force you to handle every case of a union. VB.NET cannot. I have to be disciplined, or write tests that enumerate every case. I do not pretend the type system is doing more than it is.
My adoption went roughly like this:
OneOf and ValueOf as NuGet
packages.Maybe(Of T) first, about 60 lines over
OneOf(Of T, None). The helper I needed early was
FromDBNull because SQL is everywhere in our codebase.Result(Of T, F), about 50 lines over
OneOf(Of T, F). I skipped BindIf at this point
and did not add it for months.ApiResult(Of T) last, same OneOf pattern,
with DontPanic for wrapping throwing code. We named it
after our I/O layer. IoResult or TryResult
would have worked too. BindIf got added the first time I
caught myself branching inside a Try.If/Try/null-check pyramid. I had a coworker
read the bind version. They could, so the pattern worked. If they could
not have, the pattern would have needed more
Maybe/Result adoption in the calling code
first.This is not a defense of VB.NET. The language has real costs: verbosity, weaker tooling, no native discriminated unions, no exhaustive pattern matching. If I could switch to F#, I would. If I could greenfield in F#, I would.
I am stuck with VB.NET: a fifteen-year-old codebase, a team that only
knows VB, a deployment target that requires it. So I steal the ideas
that make F# pleasant. I build railroads in VB.NET. I write pipelines
that read like recipes. I have error handling that does not lose
exceptions in nested Catch blocks.
I just do not get it for free. I wrote the bind library. I discipline
myself not to branch mid-pipeline without BindIf. I
remember the type system is not doing as much work as it would in
F#.
The imperative version still works. I just prefer chaining now.
Part 3 — Domain Modeling in VB.NET. How to use these
patterns at the architecture level. Nominal types via
ValueOf. Structures for values. Discriminated unions for
closed sets of states. A worked example. See
vbnet-domain-modeling-records.md.
| Home • Blog • 爪闩ㄒ尺工乂 • ©Copyright 2026 • |