Bind Chains in VB.NET - Maybe, Result, and Pipeline Operators

Part 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

Bind Chains in VB.NET: Maybe, Result, and Pipeline Operators

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


Where Part 1 left off

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.


Why I built this

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.

The substrate: do not roll your own

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.”


The pipeline

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:

  1. Takes a routing key.
  2. Maps it through six transformations.
  3. Returns an 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:

With bind, every step is one line. Adding a step is one line. The type system enforces short-circuiting on failure.


Composable: swap steps in and out

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:

This is what “composable” means in practice. The bind chain is the assembly line. The methods are the interchangeable stations.


Why Tee matters

Map 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:

Tee 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.


Branching without leaving the pipeline

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.


When to stop

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.


How I adopted this

My adoption went roughly like this:


What VB.NET still does not give me

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.


Next

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.

Reply to this! (Via Email)

Home • Blog • 爪闩ㄒ尺工乂 • ©Copyright 2026 •