jonnifyfsharp

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.

C2.08.1 · part 1

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 out parameter 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.
C2.08.1 · part 2

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
The trap is specific to inferred arguments. A literal call such as 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.
C2.08.1 · part 3

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

string Int32.TryParse bool * int "42" parses ? true · / · false (true, 42) (false, 0)
s = "42" · Int32.TryParse "42" = (true, 42) · the match returns Some 42
Without JavaScript: the same three parses, by hand
sInt32.TryParse sthe 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

Related in this course