Skip to content

Instantly share code, notes, and snippets.

@swlaschin
Last active July 9, 2024 15:41
Show Gist options
  • Save swlaschin/2d3e75a2ff4a87112c19309c86e0dd41 to your computer and use it in GitHub Desktop.
Save swlaschin/2d3e75a2ff4a87112c19309c86e0dd41 to your computer and use it in GitHub Desktop.
F# to C# interop tips

Tips on exposing F# to C#

Api and Methods

I suggest that you create one or more Api.fs files to expose F# code in a C# friendly way.

In this file:

  • Define functions with PascalCase names. They will appear to C# as static methods.
  • Functions should use tuple-style declarations (like C#) rather than F#-style params with spaces.
  • Also, best to explicitly document the parameters and return types

Example:

module Api

/// Note the tuple-style parameters and PascalCase name
let MyApiMethod(i:int, s:string) :bool =
   ...

Checking for null parameters

You should check parameters coming from C# as they might be null or otherwise invalid. To throw exceptions you can use:

  • nullArg paramName throws ArgumentNullException
  • invalidArg paramName message throws ArgumentException

Here's a parameter validation example:

let MyApiMethod(user:LoggedInUser, product:Product, email:string) :bool =
   if isNull (box user) then nullArg "user"
   if isNull (box product) then nullArg "product"
   if isNull email then nullArg "email"
   if not(email.Contains("@")) then invalidArg "email" "email must contain @"

   // now do something with user and product and email

Note that if a function accepts types that are defined in F# (like LoggedInUser or Product in the example above), they can't easily be tested against null, so we need to use box to convert them into an object and then test them. Other types, such as string, do not need the box.

Also note the use of if then without an else branch. Generally the else branch is required, but in this case, since the then branch returns unit (because it throws an exception), the else branch can be omitted.

Records

Records are exposed as readonly classes. No special treatment needed.

  • There is a readonly property for each class
  • There is a constructor which takes all the fields as parameters.

Note, there is no parameterless constructor available by default. If you need one (eg for serialization) use the CLIMutable attribute (explanation here).

Unions

Unions can be exposed two ways:

  1. As a new class representing the combined cases. That is, there is a separate field to hold the data for each possible case. See "Unions example 1" below.
  2. Leave the type alone but expose a "Match" method with a lambda for each case so that the C# code can pattern-match. See "Unions example 2" below.

Single Case Unions

SCUs used as wrapper types can be exposed two ways:

  1. By unwrapping them and exposing the underlying primitive string/int, etc
  2. Otherwise, leave them alone, and they are exposed as a class with
    • a single readonly property called Item
    • a static factory method NewXXX named after the class which takes one parameter. E.g. ProduceCode.NewProductCode("abc")

Collections

  • F# list should be exposed as IEnumerable (seq in F#) using List.toSeq
  • F# sets should (generally) be converted to IEnumerable (seq in F#) using Set.toSeq
  • F# maps should be converted to IDictionary<_,_>. This can be done by direct casting (myMap :> IDictionary<_,_>) or, if you need the performance of a hash table, conversion to a read-only IDictionary (myMap |> Map.toSeq |> dict)
  • For mutable collections, use arrays. F# also understands the C# List<_> type (called ResizeArray in F#) but best not to use that in an API.

Options

Optional values can be exposed two ways:

  1. By using null for None (or converting to Nullable).
    • You can set a F# reference value to null using Unchecked.defaultof<_> if you have to. See "Unions example 1" below.
  2. By converting them into a list of 1 or 0 items using Option.toList

Functions

Function can be exposed two ways:

  1. By exposing them as Func<> -- Not recommended in general unless the C# devs are familiar with functional programming. A useful exception to this rule is using them in Match methods. See "Unions example 2" below.
  2. By converting them into a interface (See "Functions example" below)

Union Example

Given a choice type like this:

type PaymentMethod = 
   | Cash
   | Card of CreditCardInfo 
   | PayPal of EmailAddress

Unions: Approach 1

Expose a new class with a field for ALL properties combined and an enum or flag to indicate what case is being used.

type PaymentMethodApi = {
   IsCash : bool
   IsCard : bool
   IsPayPal : bool  
   Card : CreditCardInfo 
   PayPal : EmailAddress
   }

or you could use an Enum instead of three flags.

type PaymentMethodCase = 
   | Cash = 1
   | Card = 2
   | PayPal = 3
   
type PaymentMethodApi = {
   PaymentMethodCase : PaymentMethodCase
   Card : CreditCardInfo 
   PayPal : EmailAddress
   }

To create a value like this, use Unchecked.defaultof<_> to assign nulls for the fields that are not used.

let convertToApi paymentMethod =
    match paymentMethod with
    | Cash ->
        {
            IsCash = true
            IsCard = false
            IsPayPal = false
            CardInfo = Unchecked.defaultof<_>
            PayPalInfo = Unchecked.defaultof<_>
        }
    | Card creditCardInfo ->
        {
            IsCash = false
            IsCard = true
            IsPayPal = false
            CardInfo = creditCardInfo
            PayPalInfo = Unchecked.defaultof<_>
        }
    | PayPal emailAddress ->
        {
            IsCash = false
            IsCard = false
            IsPayPal = true
            CardInfo = Unchecked.defaultof<_>
            PayPalInfo = emailAddress
        }

If the record structure has lots of fields, it's easier to create an "empty" record and then only set the required fields in each case.

let convertToApi paymentMethod =
    let empty =
        {
            IsCash = false
            IsCard = false
            IsPayPal = false
            CardInfo = Unchecked.defaultof<_>
            PayPalInfo = Unchecked.defaultof<_>
        }
    match paymentMethod with
    | Cash ->
        { empty with IsCash = true }
    | Card creditCardInfo ->
        { empty with IsCard = true; CardInfo = creditCardInfo }
    | PayPal emailAddress ->
        { empty with IsPayPal = true; PayPalInfo = emailAddress }

Unions: Approach 2

The other approach is to leave the F# choice type alone but also export a "match" method with a lambda for each case so that the C# code can pattern match against the cases in the same way that F# does.

/// exposed in API, hence the use of PascalCase
/// and Tuple-style parameters
let Match<'T>
    (
    (paymentMethod : PaymentMethod),
    (handleCash : System.Func<'T>),
    (handleCard : System.Func<CreditCardInfo,'T>),
    (handlePayPal : System.Func<EmailAddress,'T>)
    ) =
    match paymentMethod with
    | Cash ->
        handleCash.Invoke()
    | Card cardInfo ->
        handleCard.Invoke(cardInfo)
    | PayPal emailAddress ->
        handlePaypal.Invoke(emailAddress)

The C# code then looks like:

var paymentMethod = this.Get....
Api.Match<int>(
	paymentMethod,
	handleCash: () => {
		return 0;
		},
	handleCard: cardInfo => {
		return 1;
		},
	handlePayPal: emailAddress => {
		return 2;
		}
	);

Converting between interfaces and functions

Interfaces are more familiar than stand-alone functions in C#, so providing a one-method interface rather than a Func<_> will make the code easier to use. It's straightforward to convert between F# functions and interfaces, as shown in the examples below.

/// Declare a one-parameter F# function type
type ConvertToString = int -> string

/// Declare an equivalent one-parameter method on an interface
type IConvertToString =
    abstract Execute : int -> string

/// Declare a two-parameter F# function type
type Adder = int -> int -> int
	
/// Declare an equivalent two-parameter method on an interface
/// Note the use of "tuple" signature style
type IAdder =
    abstract Execute : int * int -> int

Here's an implementation of the Adder function type in F#. The parameters are constrained by the definition.

let adder : Adder =
    fun i j -> i + j

With these defined, we can convert between them:

/// Accept an IAdder interface from C# and
/// convert it to an internal F# function
let ApiMethod(adder:IAdder) =
    let fsharpAdder i j =   // declare an F#-friendly function
        adder.Execute(i,j)  // call the interface that was passed in
	
    // do something with fsharpAdder
    ()

We can also go the other way and create an interface from an F# function:

/// Create an IAdder interface from an internal F# function
let NewIAdder() : IAdder =
    // this "object expression" syntax implements an interface without having to define a whole class
    {new IAdder with
       member this.Execute(i,j) = adder i j  // call the internal F# function
    }
@abelbraaksma
Copy link

This is a great summary! Though I'd like to add that it's now much more common for C# people to deal with Func or Action type delegates. If these are exposed, F# will automatically convert an F# function to an Action or Func. If they're returned, from F# you can then call f.Invoke on the function. If you explicitly need to return a Func type explicitly, you can call the constructor with an F# curried function: Func<_, _> (fun x -> x + 12).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment