What's new in F# 9

F# 9 introduces a range of enhancements that make your programs safer, more resilient, and performant. This article highlights the major changes in F# 9, developed in the F# open source code repository.

F# 9 is available in .NET 9. You can download the latest .NET SDK from the .NET downloads page.

Nullable reference types

Although F# is designed to avoid null, it can creep in when interfacing with .NET libraries written in C#. F# now provides a type-safe way to deal with reference types that can have null as a valid value.

For more details, see the Nullable Reference Types in F# 9 blog post.

Here are some examples:

// Declared type at let-binding
let notAValue: string | null = null

let isAValue: string | null = "hello world"

let isNotAValue2: string = null // gives a nullability warning

let getLength (x: string | null) = x.Length // gives a nullability warning since x is a nullable string

// Parameter to a function
let len (str: string | null) =
    match str with
    | null -> -1
    | s -> s.Length  // binds a non-null result - compiler eliminated "null" after the first clause

// Parameter to a function
let len (str: string | null) =
    let s = nullArgCheck "str" str // Returns a non-null string
    s.Length  // binds a non-null result

// Declared type at let-binding
let maybeAValue: string | null = hopefullyGetAString()

// Array type signature
let f (arr: (string | null)[]) = ()

// Generic code, note 'T must be constrained to be a reference type
let findOrNull (index: int) (list: 'T list) : 'T | null when 'T : not struct =
    match List.tryItem index list with
    | Some item -> item
    | None -> null

Discriminated union .Is* properties

Discriminated unions now have auto-generated properties for each case, allowing you to check if a value is of a particular case. For example, for the following type:

type Contact =
    | Email of address: string
    | Phone of countryCode: int * number: string

type Person = { name: string; contact: Contact }

Previously, you had to write something like:

let canSendEmailTo person =
    match person.contact with
    | Email _ -> true
    | _ -> false

Now, you can instead write:

let canSendEmailTo person =
    person.contact.IsEmail

Partial active patterns can return bool instead of unit option

Previously, partial active patterns returned Some () to indicate a match and None otherwise. Now, they can also return bool.

For example, the active pattern for the following:

match key with
| CaseInsensitive "foo" -> ...
| CaseInsensitive "bar" -> ...

Was previously written as:

let (|CaseInsensitive|_|) (pattern: string) (value: string) =
    if String.Equals(value, pattern, StringComparison.OrdinalIgnoreCase) then
        Some ()
    else
        None

Now, you can instead write:

let (|CaseInsensitive|_|) (pattern: string) (value: string) =
    String.Equals(value, pattern, StringComparison.OrdinalIgnoreCase)

Prefer extension methods to intrinsic properties when arguments are provided

To align with a pattern seen in some .NET libraries, where extension methods are defined with the same names as intrinsic properties of a type, F# now resolves these extension methods instead of failing the type check.

Example:

type Foo() =
    member val X : int = 0 with get, set

[<Extension>]
type FooExt =
    [<Extension>]
    static member X (f: Foo, i: int) = f.X <- i; f

let f = Foo()

f.X(1) // We can now call the extension method to set the property and chain further calls

Empty-bodied computation expressions

F# now supports empty computation expressions.

let xs = seq { } // Empty sequence
let html =
    div {
        p { "Some content." }
        p { } // Empty paragraph
    }

Writing an empty computation expression will result in a call to the computation expression builder's Zero method.

This is a more natural syntax compared to the previously available builder { () }.

Hash directives are allowed to take non-string arguments

Hash directives for the compiler previously only allowed string arguments passed in quotes. Now, they can take any type of argument.

Previously, you had:

#nowarn "0070"
#time "on"

Now, you can write:

#nowarn 0070
#time on

This also ties into the next two changes.

Extended #help directive in fsi to show documentation in the REPL

The #help directive in F# Interactive now shows documentation for a given object or function, which you can now pass without quotes.

> #help List.map;;

Description:
Builds a new collection whose elements are the results of applying the given function
to each of the elements of the collection.

Parameters:
- mapping: The function to transform elements from the input list.
- list: The input list.
Returns:
The list of transformed elements.

Examples:
let inputs = [ "a"; "bbb"; "cc" ]

inputs |> List.map (fun x -> x.Length)
// Evaluates to [ 1; 3; 2 ]

Full name: Microsoft.FSharp.Collections.ListModule.map
Assembly: FSharp.Core.dll

See Enhancing #help in F# Interactive blog post for more details.

Allow #nowarn to support the FS prefix on error codes to disable warnings

Previously, when you wanted to disable a warning and wrote #nowarn "FS0057", you would get an Invalid warning number 'FS0057'. Even though the warning number is correct, it just wasn't supposed to have the FS prefix.

Now, you won't have to spend time figuring that out because the warning numbers are accepted even with the prefix.

All of these will now work:

#nowarn 57
#nowarn 0057
#nowarn FS0057

#nowarn "57"
#nowarn "0057"
#nowarn "FS0057"

It's a good idea to use the same style throughout your project.

Warning about TailCall attribute on non-recursive functions or let-bound values

F# now emits a warning when you put the [<TailCall>] attribute somewhere it doesn't belong. While it has no effect on what the code does, it could confuse someone reading it.

For example, these usages will now emit a warning:

[<TailCall>]
let someNonRecFun x = x + x

[<TailCall>]
let someX = 23

[<TailCall>]
let rec someRecLetBoundValue = nameof(someRecLetBoundValue)

Enforce attribute targets

The compiler now correctly enforces the AttributeTargets on let values, functions, union case declarations, implicit constructors, structs, and classes. This can prevent some hard-to-notice bugs, such as forgetting to add the unit argument to an Xunit test.

Previously, you could write:

[<Fact>]
let ``this test always fails`` =
  Assert.True(false)

When you ran the tests with dotnet test, they would pass. Since the test function is not actually a function, it was ignored by the test runner.

Now, with correct attribute enforcement, you will get an error FS0842: This attribute is not valid for use on this language element.

Random functions for collections

The List, Array, and Seq modules have new functions for random sampling and shuffling. This makes F# easier to use for common data science, machine learning, game development, and other scenarios where randomness is needed.

All functions have the following variants:

  • One that uses an implicit, thread-safe, shared Random instance
  • One that takes a Random instance as an argument
  • One that takes a custom randomizer function, which should return a float value greater than or equal to 0.0 and less than 1.0

There are four functions (each with three variants) available: Shuffle, Choice, Choices, and Sample.

Shuffle

The Shuffle functions return a new collection of the same type and size, with each item in a randomly mixed position. The chance to end up in any position is weighted evenly on the length of the collection.

let allPlayers = [ "Alice"; "Bob"; "Charlie"; "Dave" ]
let round1Order = allPlayers |> List.randomShuffle // [ "Charlie"; "Dave"; "Alice"; "Bob" ]

For arrays, there are also InPlace variants that shuffle the items in the existing array instead of creating a new one.

Choice

The Choice functions return a single random element from the given collection. The random choice is weighted evenly on the size of the collection.

let allPlayers = [ "Alice"; "Bob"; "Charlie"; "Dave" ]
let randomPlayer = allPlayers |> List.randomChoice // "Charlie"

Choices

The Choices functions select N elements from the input collection in random order, allowing elements to be selected more than once.

let weather = [ "Raining"; "Sunny"; "Snowing"; "Windy" ]
let forecastForNext3Days = weather |> List.randomChoices 3 // [ "Windy"; "Snowing"; "Windy" ]

Sample

The Sample functions select N elements from the input collection in random order, without allowing elements to be selected more than once. N cannot be greater than the collection length.

let foods = [ "Apple"; "Banana"; "Carrot"; "Donut"; "Egg" ]
let today'sMenu = foods |> List.randomSample 3 // [ "Donut"; "Apple"; "Egg" ]

For a full list of functions and their variants, see (RFC #1135).

Parameterless constructor for CustomOperationAttribute

This constructor makes it easier to create a custom operation for a computation expression builder. It uses the name of the method instead of having to explicitly name it (when in most cases the name matches the method name already).

type FooBuilder() =
    [<CustomOperation>]  // Previously had to be [<CustomOperation("bar")>]
    member _.bar(state) = state

C# collection expression support for F# lists and sets

When using F# lists and sets from C#, you can now use collection expressions to initialize them.

Instead of:

FSharpSet<int> mySet = SetModule.FromArray([1, 2, 3]);

You can now write:

FSharpSet<int> mySet = [ 1, 2, 3 ];

Collection expressions make it easier to use the F# immutable collections from C#. You might want to use the F# collections when you need their structural equality, which System.Collections.Immutable collections don't have.

Developer productivity improvements

Parser recovery

There have been continuous improvements in parser recovery, meaning that tooling (for example, syntax highlighting) still works with code when you're in the middle of editing it and it might not be syntactically correct at all times.

For example, the parser will now recover on unfinished as patterns, object expressions, enum case declarations, record declarations, complex primary constructor patterns, unresolved long identifiers, empty match clauses, missing union case fields, and missing union case field types.

Diagnostics

Diagnostics, or understanding what the compiler doesn't like about your code, are an important part of the user experience with F#. There are a number of new or improved diagnostic messages or more precise diagnostic locations in F# 9.

These include:

  • Ambiguous override method in object expression
  • Abstract members when used in non-abstract classes
  • Property that has the same name as a discriminated union case
  • Active pattern argument count mismatch
  • Unions with duplicated fields
  • Using use! with and! in computation expressions

There is also a new compile-time error for classes with over 65,520 methods in generated IL. Such classes aren't loadable by the CLR and result in a run-time error. (You won't author that many methods, but there have been cases with generated code.)

Real visibility

There is a quirk with how F# generates assemblies that results in private members being written to IL as internal. This allows inappropriate access to private members from non-F# projects that have access to an F# project via InternalsVisibleTo.

Now, there is an opt-in fix for this behavior available via the --realsig+ compiler flag. Try it in your solution to see if any of your projects depend on this behavior. You can add it to your .fsproj files like this:

<PropertyGroup>
    <RealSig>true</RealSig>
</PropertyGroup>

Performance improvements

Optimized equality checks

Equality checks are now faster and allocate less memory.

For example:

[<Struct>]
type MyId =
    val Id: int
    new id = { Id = id }

let ids = Array.init 1000 MyId
let missingId = MyId -1

// used to box 1000 times, doesn't box anymore
let _ = ids |> Array.contains missingId

Benchmark results for affected array functions, applied to a 2-member struct

Before:

Method Mean Error Gen0 Allocated
ArrayContainsExisting 15.48 ns 0.398 ns 0.0008 48 B
ArrayContainsNonexisting 5,190.95 ns 103.533 ns 0.3891 24000 B
ArrayExistsExisting 17.97 ns 0.389 ns 0.0012 72 B
ArrayExistsNonexisting 5,316.64 ns 103.776 ns 0.3891 24024 B
ArrayTryFindExisting 24.80 ns 0.554 ns 0.0015 96 B
ArrayTryFindNonexisting 5,139.58 ns 260.949 ns 0.3891 24024 B
ArrayTryFindIndexExisting 15.92 ns 0.526 ns 0.0015 96 B
ArrayTryFindIndexNonexisting 4,349.13 ns 100.750 ns 0.3891 24024 B

After:

Method Mean Error Gen0 Allocated
ArrayContainsExisting 4.865 ns 0.3452 ns - -
ArrayContainsNonexisting 766.005 ns 15.2003 ns - -
ArrayExistsExisting 8.025 ns 0.1966 ns 0.0004 24 B
ArrayExistsNonexisting 834.811 ns 16.2784 ns - 24 B
ArrayTryFindExisting 16.401 ns 0.3932 ns 0.0008 48 B
ArrayTryFindNonexisting 1,140.515 ns 22.7372 ns - 24 B
ArrayTryFindIndexExisting 14.864 ns 0.3648 ns 0.0008 48 B
ArrayTryFindIndexNonexisting 990.028 ns 19.7157 ns - 24 B

You can read all the details here: F# Developer Stories: How we’ve finally fixed a 9-year-old performance issue.

Field sharing for struct discriminated unions

If fields in multiple cases of a struct discriminated union have the same name and type, they can share the same memory location, reducing the struct's memory footprint. (Previously, same field names weren't allowed, so there are no issues with binary compatibility.)

For example:

[<Struct>]
type MyStructDU =
    | Length of int64<meter>
    | Time of int64<second>
    | Temperature of int64<kelvin>
    | Pressure of int64<pascal>
    | Abbrev of TypeAbbreviationForInt64
    | JustPlain of int64
    | MyUnit of int64<MyUnit>

sizeof<MyStructDU> // 16 bytes

Comparing to previous verion (where you had to use unique field names):

[<Struct>]
type MyStructDU =
    | Length of length: int64<meter>
    | Time of time: int64<second>
    | Temperature of temperature: int64<kelvin>
    | Pressure of pressure: int64<pascal>
    | Abbrev of abbrev: TypeAbbreviationForInt64
    | JustPlain of plain: int64
    | MyUnit of myUnit: int64<MyUnit>

sizeof<MyStructDU> // 60 bytes

Integral range optimizations

The compiler now generates optimized code for more instances of start..finish and start..step..finish expressions. Previously, these were only optimized when the type was int/int32 and the step was a constant 1 or -1. Other integral types and different step values used an inefficient IEnumerable-based implementation. Now, all of these are optimized.

This leads to anywhere from 1.25× up to 8× speed up in loops:

for … in start..finish do …

List/array expressions:

[start..step..finish]

and comprehensions:

[for n in start..finish -> f n]

Optimized for x in xs -> … in list and array comprehensions

On a related note, comprehensions with for x in xs -> … have been optimized for lists and arrays, with notable improvements especially for arrays, with speedups up to 10× and ⅓ to ¼ allocation size.

Improvements in tooling

Live buffers in Visual Studio

This previously opt-in feature has been thoroughly tested and is now enabled by default. The background compiler powering the IDE now works with live file buffers, meaning you don't have to save the files to disk to get the changes applied. Previously, this could cause some unexpected behavior. (Most notoriously when you tried to rename a symbol present in a file that had been edited but not saved.)

Analyzer and code fix for removing unnecessary parentheses

Sometimes extra parentheses are used for clarity, but sometimes they are just noise. For the latter case, you now get a code fix in Visual Studio to remove them.

For example:

let f (x) = x // -> let f x = x
let _ = (2 * 2) + 3 // -> let _ = 2 * 2 + 3

Custom visualizer support for F# in Visual Studio

The debugger visualizer in Visual Studio now works with F# projects.

debug visualizer

Signature tooltips shown mid-pipeline

Previously, signature help wasn't offered in a situation like the following, where a function in the middle of a pipeline already had a complex curried parameter (for example, a lambda) applied to it. Now, the signature tooltip shows up for the next parameter (state):

tooltip