Dive C2.03.3
The interop boundary
Null and throws live at the edge. Convert there, and the core stays clean.
The two earlier dives describe an F# core with no null and no failure
thrown for control flow. Real programs do not run in isolation: they call .NET libraries written in C#, and
those return null for a missing value and throw when an
operation cannot proceed. The discipline is a seam. At the edge where C# results cross into
F#, convert each one — a null reference into an Option with
Option.ofObj, a Nullable<T> with
Option.ofNullable, a throwing call into a Result by catching
at the boundary. Past that line, every value is an Option or a
Result, and the rest of the code never touches a null or a
try again.
A null reference becomes an Option
A C# method may return null for a reference type. FSharp.Core's
Option.ofObj takes that value and returns None when it is
null and Some v otherwise — the conversion is one call. For
a value type wrapped in Nullable<T>,
Option.ofNullable does the same. Both belong at the boundary, applied the moment the
value arrives.
// a reference that might be null -> Option, at the edge Option.ofObj "hi" // val it : string option = Some "hi" Option.ofObj (null: string) // val it : string option = None // a Nullable<int> -> Option, at the edge System.Nullable<int>(5) |> Option.ofNullable // val it : int option = Some 5
- Option.ofObj
- a nullable reference →
Option:nullbecomesNone, anything elseSome. - Option.ofNullable
- a
System.Nullable<T>→Option, the same way for value types. - the rule
- apply the conversion at the call site that crosses the boundary; never let a
nulltravel deeper into F#.
A throwing call becomes a Result
For a call that throws, the boundary wraps it in a try and returns a
Result: Ok for the value, Error for
the caught failure. The with :? System.FormatException pattern catches one specific
exception type — a deliberately narrow net, so a genuinely unexpected exception still propagates rather than
being silently turned into an Error.
C# — the throwing API as the reader knows it
int n = int.Parse(s); // returns 5 for "5"; throws FormatException for "x"
F# — caught at the seam, returned as a Result
let tryParseInt (s: string) = try Ok (System.Int32.Parse s) with :? System.FormatException -> Error "bad format" // val tryParseInt : string -> Result<int,string> tryParseInt "5" // val it : Result<int,string> = Ok 5 tryParseInt "x" // val it : Result<int,string> = Error "bad format"
try sits at the seam, not scattered through the core. One wrapper
converts the throwing call once; every function that uses tryParseInt downstream reads
an Ok or an Error and never writes a
try of its own.Cross the seam
Three calls arrive from the C# side: one returns a value, one returns null, one
throws. Pick one and watch it cross the boundary. The conversion at the seam turns each into a typed F#
value — Some or None for the nullable lookup,
Ok or Error for the throwing parse. Past the line, nothing
downstream sees a null or a thrown exception.
the C# side · the seam · the F# core
| the C# call | the seam converts with | the F# value |
|---|---|---|
| int.Parse "5" | try → Result | Ok 5 |
| int.Parse "x" (throws) | try → Result | Error "bad format" |
| lookup → null | Option.ofObj | None |
The seam is one line of code per crossing: a try wrapper, or an
Option.ofObj. It is the only place in the program that knows
null and throws exist.
The rule: convert once, at the edge
The module closes on one discipline. null and exceptions are facts of the .NET
platform, and F# can produce and consume both when it has to. The choice is where: at the boundary,
in a thin layer of conversions, and nowhere else. A function deep in the core takes an
Option or a Result and returns one; it is never handed a
null and never has to guard against a throw, because the seam already did.
The platform
.NET libraries return null and throw — facts F# inherits at every interop call.
The discipline
Convert at the seam — Option.ofObj · ofNullable · a try-wrap — and keep the core all Option and Result.
What this module covered
Three dives, two types, one boundary. Option retires
null; Result retires the exception-as-control-flow; and the
seam is where the C# that still does both is converted on the way in. The same two types run deeper through
the language in chapter C1 — bind, the railway, and the computation expressions that
thread them.
References
Sources
- Microsoft — F# documentation — interoperating with .NET, and converting null and exceptions at the boundary.
- FSharp.Core API reference —
Option.ofObjandOption.ofNullable. - Microsoft — .NET documentation —
Nullable<T>,FormatException, and exception handling on the CLR.
Related in this course
- C2.03.1 — From null to Option — the
Optionthe seam produces. - C1.08 — Option, Result & railway-oriented errors — threading these types in language depth.
- Elixir — Purity — the BEAM's returned
{:ok, v}/{:error, reason}at its own boundaries.