Dive C2.08.1
Calling C# from F#
A method call is a method call — except the out parameter comes back as a tuple.
F# and C# share the CLR, so calling a .NET method from F# needs no glue: name the type, name the
method, pass the arguments. The standout difference a C# developer meets first is the out
parameter. C# assigns it through a variable; F# returns it as the second half of a tuple, so
System.Int32.TryParse "42" has type bool * int and value
(true, 42). The second surprise is newer: in .NET 9, calling
TryParse on an inferred argument is ambiguous and fails to compile with
FS0041 — the fix is one type annotation. Named arguments and the rest of the BCL work
as expected.
The out parameter comes back as a tuple
A BCL method with an out parameter has two results: the return value and the value
written through out. C# carries the second through a variable the caller declares. F#
has no such variable in the calling expression — instead it gathers both results into a
tuple, return value first, out value second. So
Int32.TryParse, whose C# shape is bool TryParse(string, out int),
is called in F# as a plain expression and yields a bool * int.
C# — the out value travels through a variable
bool ok = int.TryParse("42", out var v); // ok = true, v = 42 — the caller declares v to receive the out result
F# — both results return as a tuple
let okv = System.Int32.TryParse "42" // val okv : bool * int = (true, 42) // destructure the tuple to read both halves: let (ok, v) = System.Int32.TryParse "42" // ok = true, v = 42
The idiomatic move is to match the tuple directly, turning the
(bool * int) into the Option the F# core uses. The
true, v pattern binds the parsed value; the _ arm catches
the failed parse. The result is a clean string -> int option.
let parse (s: string) = match System.Int32.TryParse s with | true, v -> Some v | _ -> None // val parse : string -> int option parse "7" // val it : int option = Some 7 parse "x" // val it : int option = None
- out → tuple
- a method's
outparameter returns as the second tuple element; the return value is first. - bool * int
- the static type of
Int32.TryParse s— the success flag paired with the parsed value. - match true, v
- the success pattern binds the parsed value; a wildcard arm handles the failed parse, yielding an
Option.
The .NET 9 trap: an inferred argument is ambiguous
One detail bites in .NET 9 that did not before. Int32.TryParse now has overloads for
string, ReadOnlySpan<char>, and
ReadOnlySpan<byte>. When the argument's type is inferred, the F# compiler
cannot decide which overload to call, and reports FS0041 at the call site. The error
is precise about why: the argument's known type is the type variable 'a, and several
candidates fit.
// inferred parameter — the overload cannot be resolved: let parseBad s = System.Int32.TryParse s // error FS0041: A unique overload for method 'TryParse' could not be determined // based on type information prior to this program point. A type annotation may be needed. // Known type of argument: 'a // Candidates: // - System.Int32.TryParse(s: System.ReadOnlySpan<char>, result: byref<int>) : bool // - System.Int32.TryParse(s: string, result: byref<int>) : bool // - System.Int32.TryParse(utf8Text: System.ReadOnlySpan<byte>, result: byref<int>) : bool
The fix is one annotation. Naming the parameter (s: string) tells the compiler which
overload to bind, and the call compiles. This is why parse above annotates its
parameter — without the annotation it would not compile on .NET 9.
F# — annotate the parameter and the call resolves
let parse (s: string) = // the (s: string) annotation picks the string overload match System.Int32.TryParse s with | true, v -> Some v | _ -> None // val parse : string -> int option — compiles cleanly
System.Int32.TryParse "42" resolves fine, because the string literal fixes the type.
The annotation is needed only when the parameter's type is otherwise unknown at the call site.Named arguments, arrays, and the rest of the BCL
Past the out-tuple and the overload trap, the BCL behaves the way a C# developer
expects. Static methods are called by their fully-qualified name; an F# array passes where the method takes an
array; and named arguments work on BCL methods, which reads especially well for boolean flags
— ignoreCase = true says what the true means at the call site.
// an F# array passes straight to a BCL method: System.String.Join(", ", [| "a"; "b"; "c" |]) // val it : string = "a, b, c" // a named argument on a BCL method — the flag reads as what it is: System.String.Compare("a", "b", ignoreCase = true) // val it : int = -1 System.Math.Max(3, 7) // val it : int = 7
The C# call
A static method, an out parameter through a variable, a positional or named argument, an array.
The F# form
The same method by name, the out result as a tuple, named args (ignoreCase = true), an F# array passed directly.
Unpack Int32.TryParse s
Pick a string. Int32.TryParse returns a (bool * int) tuple:
the true flag with the parsed value when the string is a number,
the false flag with 0 when it is not. The
readout shows the exact FSI tuple, and the arm the match takes — the
parse function above turns each into Some or
None.
Int32.TryParse s · a (bool * int) tuple, then the match arm
| s | Int32.TryParse s | the match returns |
|---|---|---|
| "42" | (true, 42) | Some 42 |
| "7" | (true, 7) | Some 7 |
| "x" | (false, 0) | None |
The out parameter is not lost and not magic — it is the second half of an
ordinary tuple, and a match reads it straight into the F# core's
Option.
Where this leads
This dive went one way across the seam — F# calling C#. The next goes the other way: how an F#
module, record, union, and function value appear from C#, and the
[<CLIMutable>] attribute that makes a record friendly to C# constructors and
serializers. The dive after contains the one type the F# core never holds — null.
References
Sources
- Microsoft — F# documentation — calling .NET from F#, named and optional arguments, and method-overload resolution.
- Microsoft — .NET documentation —
Int32.TryParse, itsReadOnlySpanoverloads,String.Join,String.Compare, andMath.Max. - The F# language specification — tuples, pattern matching, and the resolution of
out/byrefparameters.
Related in this course
- C2.08.2 — Exposing F# to C# — the other direction across the seam.
- C2.03 — From null to Option, from exceptions to Result — the
Optionthe tuple-match produces. - C1.08 — Option, Result & railway-oriented errors —
Optionin language depth.