R
RNDr. Tomáš Grošup, Ph.D.
Guest
This article describes the history of nullability in F# and how the feature builds upon in to deliver improved null safety in F# code as well as in interoperability scenarios with other languages. F#’s Nullable Reference Types are part of F# 9 which shipped along side .NET 9.
The
F# has long been at the forefront of making it possible and practical to code without the routine use of
F#, however, has always been a highly interoperable language, compiling to both .NET and JavaScript and interoperating with the rich libraries of these ecosystems. In .NET, every reference type variable can in theory be a
Let’s briefly recap how F# today helps you code safely, without the regular use of
This does not only limit assignment of the
The possibility to assign and compare against the
For generic code working with the
Instead of using the
In imperative programming, uninitialized fields and variables are a common source of
With that feature set, the use of
The null safety described in previous section only holds for F#-only code. Reference types coming via interop scenarios from other languages do not give these guarantees, and were therefore treated as if they had the
C# introduced Nullable Reference Types with C# 8, and released together with .NET Core 3.1. It was later improved in C# 9 and with the release of .NET 5, most of the .NET base class library was properly annotated. This feature does not change the runtime behavior of reference types, but it does allow for code to be annotated for possible nullability of reference types, and C# compiler can check that
As time passed, more and more libraries began to be annotated and C# compiler could see via metadata which fields/members/properties/arguments/type parameters/… can potentially carry a null value and which are claimed by the author to be without nulls and create a form of an API contract about nullability (this is still just a claim, since the authoring code could have been suppressing nullness warnings, either centrally or via selective usage of the C#
F# Nullable Reference Types are a new optional feature closing the gap of null-safety in language interoperability scenarios. While primarily designed for interop with C#, this can in principle be used when F# is compiled to other target languages like JavaScript or Python.
This feature introduces nullness into the F# type system, making it clear to the compiler which reference types can carry a null value, and which ones are guaranteed to be without null. If a possibly nullable value is either dereferenced (e.g. by accessing an instance member, field or a property) or passed as an argument to a non-nullable parameter, a new compiler warning is raised. Compiler passes the information across usages, both within the same project and across projects. The information is also written out to the compiled .dll in the same metadata form which C# understands, making the nullability of F# values also visible to C# consumers.
The feature is not meant to be a replacement for
With the .NET 9 release, this feature is off by default. To turn it on, the following property must be turned on in your project file:
This in turn automatically passes the
As any other project property, it can be also set in a Directory.Build.props and shared across many projects. The same property naming is used also for C# Nullable Reference Types, allowing mixed C#/F# solutions to use the same property.
If you do use a shared Directory.Build.props file and want to conditionally apply
In a project using
To explicitly opt-in into nullability, a type declaration has to be suffixed with the new syntax:
The bar symbol
The nullable annotation
Notice how in the last example, the nullness annotation can be arbitrarily nested in a deeper generic application. What is the meaning of the last type definition
Since the nullable type annotation is available for type aliases, it is possible to define customized shorthand for nullable version of specific types, such as
The FSharp.Core library uses this to define a new type alias,
The bar symbol
Wrapping the same type into a pair of
When used in pattern matching,
This snippet is actually equivalent to code first doing a type test against the
A second addition is a new type parameter constraint
This is a complement to the existing
The F# compiler guards for invalid usages of nullable types. Among others, the following aspects of nullability are being checked:
Here are examples of new warnings:
There is also a new warning related to type declarations and overrides of the
Custom overrides of
Thanks to this addition, code consuming
F# 9 brings in a combination of compiler features and additions to the standard library to enable safe handling of null values. They all enable to conditionally extract a non-null value out of a potentially-null input argument, and either safely handling the null-case or throwing an exception if null is encountered.
Pattern matching against the
The binding
A newly added active pattern
If the value matched with the
A common pattern for handling null arguments is to check them at the beginning of a function, and throw an exception if null is encountered. This might be useful when fitting into existing null-allowing contracts, if enforcing a non-nullable argument is not possible.
The
If a dedicated named argument into
A new active pattern called
What does this snippet do? To the outside, it exposes a function of type
Sometimes a missing value indicated by
This function converts from
It can be used as follows:
An inverse function called
Using pattern matching, active patterns and library features are the recommended tools to handle
In F#,
For recurring checks, we recommend wrapping null-handling constructs into custom reusable active patterns:
There might be situations where a safe solution cannot be proven by the F# compiler, but certain usage of an API makes null impossible. In the following example, the
It will not do any compile-time nor run-time check, and will only change the type of the value into the non-nullable version (effectively removing
Users of C# are familiar with the
Type test, downcast and unboxing are operations whose result cannot be statically determined at compile-time, they rely on runtime behavior of a program. Nullable Reference Types do not alter the runtime representation of a type, and runtime is not aware of type nullability.
Unboxing into nullable versions of types is not possible, because the runtime value of
It is possible to use nullable annotation for runtime type tests (and downcasts and unboxing) for inner type parameters of a type. In that case, the compiler allows either version, and it is up to the programmer to validate this being correct. For example in pattern matching,
For safely extracting non-nullable elements of a list of nullable types (and skipping all
An aspect which F# programmers love is automatic type inference. In many typical F#-coding situations, users do not have to specify types – of bindings, of function parameters or their return values. As most other languages from the ML family, F# also uses the Hindley-Milner type inference.
The design goal of Nullable Reference Types with respect to type inference was to smoothly support non-nullable codebases. If an existing F# program avoided nulls and handled them at program boundaries by pattern matching or conversion to
Nullability is automatically inferred for return values of consumed APIs, as well as
For function arguments, F# keeps a null-avoiding stance. In F# as well as in C#, a null-annotated parameter means that
When the syntactical construct
This is different from C# where
F# does not support flow analysis and will not be able to eliminate nullness for APIs in
F# however does try to generate IL code with the mentioned attributes and make the life easier for C# users consuming F#-defined types. Most notably, this affects
F# 9 brings in the optional
Handling nulls has the best support in pattern matching, active patterns (
We are considering a follow-up blog post related to practical aspects of a project’s migration. Are you interested? Do you have a publicly available project which you want to migrate and are willing to share your experience with others via this blog? Please let us know in the comments.
F# is developed as a collaboration between the .NET Foundation, the F# Software Foundation, their members and other contributors including Microsoft. The F# community is involved at all stages of innovation, design, implementation and delivery, and we’re proud to be a contributing part of this community.
The feature is being released as part of the .NET 9 SDK under an optional project switch
Did you encounter an issue? Please submit it dotnet/fsharp. All issues related to Nullable Reference Types can be found under the Area-Nullness label.
We want to step into the design of
We continue working on F#: be it the language itself, compiler performance, Visual Studio features and improvements and many other aspects of F#.
The post Nullable Reference Types in F# 9 appeared first on .NET Blog.
Continue reading...
History of nullability in F#
The
null
reference was famously admitted by Tony Hoare to be his Billion-Dollar mistake, with many developers claiming it to have cost a lot more. Many of us, developers, do have experience with getting a NullReferenceException
at runtime and have spent considerable amount of time trying to fix bugs related to it. Given Tony Hoare’s presence at Microsoft Research, it was natural for programming languages researchers to rectify this mistake in language design.F# has long been at the forefront of making it possible and practical to code without the routine use of
null
. F# avoided null
values for F#-only code in its early design, and for idiomatic F#-originated code, assigning null
to a value leads to a compile-time error. For many years, F# was the only language widely available on the JVM or .NET runtimes which routinely avoided null
values.F#, however, has always been a highly interoperable language, compiling to both .NET and JavaScript and interoperating with the rich libraries of these ecosystems. In .NET, every reference type variable can in theory be a
null
reference instead of pointing to real data. This is opposite to value types
, where a variable carries the data directly instead of being a reference and can never be null. This means F# developers using .NET can be exposed to null
values through interop with C# code. Likewise F# developers compiling to JavaScript can be exposed to null
values through interop with JavaScript or TypeScript code.Null-related language features in F# 1.0 to F# 8
Let’s briefly recap how F# today helps you code safely, without the regular use of
null
values. First, the main building blocks of F# types – records, discriminated unions, tuples, functions and anonymous records – all prevent assignment of null
:
Code:
type Person = { Name : string}
type Vehicle = Car | Bike
type TwoStrings = string*string
let x : Person = null //The type 'Person' does not have 'null' as a proper value
let x : Vehicle = null //The type 'Vehicle' does not have 'null' as a proper value
let x : TwoStrings = null //The type 'TwoStrings' does not have 'null' as a proper value
let x : {|X:int|} = null //The type '{| X: int |}' does not have 'null' as a proper value
This does not only limit assignment of the
null
literal, but even matching against it. If for interoperability reasons it was necessary to compare such a value against null, box x
was needed to even allow comparison against null.The possibility to assign and compare against the
null
reference can be added by the usage of the AllowNullLiteral attribute. That attribute can only be placed on custom types, and is not allowed on records, unions, type abbreviations or structs.
Code:
[<AllowNullLiteral>]
type NullPersonRecord = { Name : string} // Records, union, abbreviations and struct types cannot have the 'AllowNullLiteral' attribute
[<AllowNullLiteral>]
type NullPersonType(name: string) =
member _.Name = name
let x : NullPersonType = null
For generic code working with the
null
literal, the T:null
generic constraint was automatically inferred by the F# compiler and enforced by specific instantiations of the generic type.
Code:
let dowork1 x = // x inferred to be a generic type requiring the 'null' constraint
let mutable z = x
z <- null
(x,z)
let dowork2 x = // x inferred to be a generic type requiring the 'null' constraint
match x with
| null -> 0
| _ -> 1
Instead of using the
null
reference in idiomatic F# programming, F# promotes the usage of option type to indicate a possible absence of value. The option type flows through the type system and enforces proper handling of both possibilities (Some
data, or None
) when handled via pattern matching. On top of that, the FSharp.Core standard library provides many functions in the Option
module for safely dealing with optionality.In imperative programming, uninitialized fields and variables are a common source of
null
values. F# is a language built around expressions, and F# programmer is not exposed to values before their initialization:- F# avoids
null
values by the widespread use oflet
to define values in modules, combined with sound initialization semantics, and by embracing primary constructors for classes to ensure fields of objects are well-initialized. In F#,let
can be trusted, as a value cannot be accessed before it is initialized. - Try/with/finally in F# is an expression. This means there’s no need to use
let mutable x = null
orlet x
; to capture the results of a try/catch/finally as you do in TypeScript and C#. The same applies for if/then/else. - F# uses
[|..|]
collection expressions which are designed to prevent null and to prevent access to an array of uninitialized values.
With that feature set, the use of
null
in F# programs is typically reduced to interoperability with projects and libraries written in other languages, most notably C#, or to low-level scenarios. It is still possible to create a null reference via the Unchecked.defaultof<>
or Array.zeroCreate<>
functions, but the name already indicates this is a dangerous operation.
Code:
let x = Unchecked.defaultof<TwoStrings>
let manyX = Array.zeroCreate<Person> 50
Interoperability with C#
The null safety described in previous section only holds for F#-only code. Reference types coming via interop scenarios from other languages do not give these guarantees, and were therefore treated as if they had the
[<AllowNullLiteral>]
as well. This means that very basic types such as string (alias to System.String
), coming from system or third party non-F# libraries, had the potential of carrying a null value.
Code:
let x : string = null // Possible for any non-F# reference type
let workWithString (s: string) =
match s with
| null -> 42
| nonNullString -> 0// Previous compiler has no way to tell that nonNullString is actually a non-null value here
C# introduced Nullable Reference Types with C# 8, and released together with .NET Core 3.1. It was later improved in C# 9 and with the release of .NET 5, most of the .NET base class library was properly annotated. This feature does not change the runtime behavior of reference types, but it does allow for code to be annotated for possible nullability of reference types, and C# compiler can check that
null
values are being properly handled. Even though the annotations are not represented at runtime, they are part of the compiled .dll files in the form of metadata as attributes.As time passed, more and more libraries began to be annotated and C# compiler could see via metadata which fields/members/properties/arguments/type parameters/… can potentially carry a null value and which are claimed by the author to be without nulls and create a form of an API contract about nullability (this is still just a claim, since the authoring code could have been suppressing nullness warnings, either centrally or via selective usage of the C#
!
operator).Welcome F# Nullable Reference Types in F# 9
F# Nullable Reference Types are a new optional feature closing the gap of null-safety in language interoperability scenarios. While primarily designed for interop with C#, this can in principle be used when F# is compiled to other target languages like JavaScript or Python.
This feature introduces nullness into the F# type system, making it clear to the compiler which reference types can carry a null value, and which ones are guaranteed to be without null. If a possibly nullable value is either dereferenced (e.g. by accessing an instance member, field or a property) or passed as an argument to a non-nullable parameter, a new compiler warning is raised. Compiler passes the information across usages, both within the same project and across projects. The information is also written out to the compiled .dll in the same metadata form which C# understands, making the nullability of F# values also visible to C# consumers.
The feature is not meant to be a replacement for
Option<_>
, Result<_>
or custom union types, and those are still considered to be more appropriate tools for the F# type system. The main area of usage should be fairly limited to either consumption of existing libraries and frameworks, or for exposing code to be consumed by other languages.Turning the feature on
With the .NET 9 release, this feature is off by default. To turn it on, the following property must be turned on in your project file:
<Nullable>enable</Nullable>
This in turn automatically passes the
--checknulls+
flag to the F# compiler, and also automatically sets a define:NULLABLE
preprocessor directive for your build. This can come in handy while initially rolling out the feature, to conditionally change conflicting code by #if NULLABLE
hash directives.As any other project property, it can be also set in a Directory.Build.props and shared across many projects. The same property naming is used also for C# Nullable Reference Types, allowing mixed C#/F# solutions to use the same property.
If you do use a shared Directory.Build.props file and want to conditionally apply
<Nullable>
for either C# or F# (but not both), you can use the following msbuild condition:<Nullable Condition="'$(MSBuildProjectExtension)' == '.fsproj'">enable</Nullable>
Alternatively, you can also selectively override the shared value for<Nullable>
from Directory.Build.props in the individual*.fsproj/*.csproj
files.
Syntax additions
In a project using
<Nullable>enable</Nullable>
setting, reference types are considered non-nullable by default. If a function accepts a parameter of type string (let myFunction(x: string) = ...
), it is considered to be a non-nullable string and passing in null
will produce a warning.To explicitly opt-in into nullability, a type declaration has to be suffixed with the new syntax:
type | null
The bar symbol
|
has the meaning of a logical OR in the syntax, building a union of two disjoint sets of types: the underlying type, and the nullable reference. This is the same syntactical symbol which is used for declaring multiple cases of an F# discriminated union: type AB = A | B
carries the meaning of either A
, or B
.The nullable annotation
| null
can be used at all places where a reference type would be normally used:- Fields of union types, record types and custom types.
- Type aliases to existing types.
- Type applications of a generic type.
- Explicit type annotations to let bindings, parameters or return types.
- Type annotations to object-programming constructs like members, properties or fields.
Code:
type AB = A | B
type AbNull = AB | null
type RecordField = { X: string | null }
type TupleField = string * string | null
type NestedGenerics = { Z : List<List<string | null> | null> | null }
Notice how in the last example, the nullness annotation can be arbitrarily nested in a deeper generic application. What is the meaning of the last type definition
NestedGenerics
? It is either null, or a list of lists of strings. Where both the inner lists and innermost strings can be null. Indeed, such types can quickly be confusing, but working with existing code can produce nullness on multiple levels similar to this. Luckily, the compiler will make sure that possible nullness is being handled at every level and warn otherwise.Since the nullable type annotation is available for type aliases, it is possible to define customized shorthand for nullable version of specific types, such as
NullString
. It even is possible to define a generic Maybe<T>
container equivalent to T | null
– despite not being recommended in general, it might come in handy in migration scenarios for gradually introducing the <Nullable>
switch into a codebase.
Code:
module TypeAliasesZoo =
type NullString = string | null
type Maybe<'T> = 'T | null
let myFunction (x: NullString) = x
let myFunction2 (x: Maybe<string>) = x
The FSharp.Core library uses this to define a new type alias,
type objnull = obj | null
. Frequently code using obj
interacts with nulls already, via reflection or boxing/unboxing. This new type alias is visible even for projects not turning on <Nullable>
– acting as an API description without any compiler checks.The bar symbol
|
does have other usages in F# which might lead to syntactical ambiguities. In such cases, parentheses are needed around the null-annotated type:
Code:
type DUField = N of string | null
//Unexpected symbol '|' (directly before 'null') in member definition
Wrapping the same type into a pair of
( )
parentheses fixes the issue:type DUField = N of (string | null)
When used in pattern matching,
|
is used to separate different pattern matching clauses.
Code:
match x with
| ?: string | null -> //...
This snippet is actually equivalent to code first doing a type test against the
string
type, and then having a separate clause for handling null:
Code:
match x with
| ?: string
| null -> // ...
A second addition is a new type parameter constraint
T: not null
. This constraint disallows any nullable type, including both | null
annotated types and types which have null as their representation value (such as the option
type). Typically, this constraint will be automatically inferred from consumed APIs, such as for most TKey
types in Dictionary-like types. The T: not null
does allow value types, since those can be never null.This is a complement to the existing
T: null
constraint, which does the opposite – only allows types which can be assigned the null
value.Warnings and errors
The F# compiler guards for invalid usages of nullable types. Among others, the following aspects of nullability are being checked:
- Accessing an instance member on a
T | null
type leads to a warning. - Passing a
T | null
argument to a function acceptingT
leads to a warning.- The opposite is not a warning. You can safely pass
T
to a function taking an argument ofT | null
type.
- The opposite is not a warning. You can safely pass
- Passing the
null
literal to a function accepting non-nullable argument type produces a warning. - Using library functions to handle null (e.g.
Option.ofObj
) with an already non-nullable value produces a warning. This is to eliminate redundant null checks. - Pattern matching a non-nullable value against
null
produces a warning. - Applying a nullable type to a generic type parameter marked as
T: not null
produces a warning. - Adding the
| null
nullness annotation to a value type leads to a compiler error. Unlike the previous diagnostics, this is not just a warning but a hard error. Combining value types andnull
would lead to invalid .NET IL produced.
Here are examples of new warnings:
Code:
module NullableWarnings =
open FSharpSyntax
open System.Collections.Generic
// FS3261: Nullness warning: The types 'AB' and 'AB | null' do not have compatible nullability.
let instanceMethod (x: AB | null) = x.IsA
// FS3261: Nullness warning: The types 'string' and 'string | null' do not have equivalent nullability.
let methodArgument (s: string | null) = File.Create s
// FS3261: Nullness warning: The type 'string' does not support 'null'.
let methodArgumentWithLiteral() = File.Create null
// FS3262: Value known to be without null passed to a function meant for nullables: You can create 'Some value' directly instead of 'ofObj', or consider not using an option for this value.
let uselessNullConversion(s: string) = Option.ofObj s
// FS3261: Nullness warning: The type 'string' does not support 'null'.
let uselessNullCheck(s: string) = match s with null -> () | _ -> ()
// FS3261: Nullness warning: The type 'string | null' supports 'null' but a non-null type is expected.
let invalidGenericTypeParameter() = Dictionary<string | null,int>()
// FS3260: The type 'int' does not support a nullness qualification.
// FS00430: A generic construct requires that the type 'int' have reference semantics, but it does not, i.e. it is a struct
let cannotAddnullToValueType() : int | null = 15
let systemNullableWorks() = Nullable<int>(15)
// Nullness warning: The types 'AB' and 'AB | null' do not have compatible nullability.
let processAB (x: AB | null) = x.IsA
// Nullness warning: The types 'string' and 'string | null' do not have compatible nullability.
let createFile (s: string | null) = File.Create
Warning for overrides of ToString()
There is also a new warning related to type declarations and overrides of the
ToString()
function. Even though the System.Object.ToString()
method from the base class library is annotated as returning string | null
, this is not the norm for F# code. Basic F# types like records, union types and anonymous records come with a compiler-generated ToString()
implementation which does not return a null
string.Custom overrides of
ToString()
are now being checked for returning a non-nullable string if the project has <Nullable>enable</Nullable
, otherwise a new warning is produced. The built-in string
function can be used to convert a potentially nullable (string | null
) value into a string
with empty string ""
being used as a replacement for null
.Thanks to this addition, code consuming
ToString()
from F# types does not have to check the return value for null
on every usage.
Code:
type NullToString = A | B | C
with override this.ToString() = if this=A then null else "not A"
// FS3263: With nullness checking enabled, overrides of .ToString() method must return a non-nullable string. You can handle potential nulls via the built-in string function.
Handling nulls
F# 9 brings in a combination of compiler features and additions to the standard library to enable safe handling of null values. They all enable to conditionally extract a non-null value out of a potentially-null input argument, and either safely handling the null-case or throwing an exception if null is encountered.
Pattern matching against the
null
literal has been extended to automatically derive non-nullness of the matched value for subsequent clauses. In a larger pattern match statement, the clauses are evaluated in a logical top-down order. Therefore, if the first clause handles the null
case, all other clauses don’t have to process null anymore.
Code:
let matchNullableString(s: string | null) =
match s with // `s` is of type string | null
| null -> 0
| notNull -> notNull.Length // `notNull` is of type string
The binding
notNull
has been derived to have the type string
, without null. The same technique works for matching tuples of arguments, with keeping track about handled null for each of the elements. This feature is limited to direct values and tuples of several values, but is not capable of eliminating nullability from nested fields (e.g. fields of a union type or of a record).A newly added active pattern
| Null | NonNull x|
can handle nullability on an arbitrary level within a match clause, while retaining the completeness check of all possibilities being covered. It can be combined with matching on unions, records and even lists of values.
Code:
type ABNull = A | B of (string | null)
let handleUnion abnull =
match abnull with
| A -> 0
| B Null -> 0
| B (NonNull s) -> s.Length // `s` is derived to be `string`
If the value matched with the
NonNull
active pattern was already compiler-checked to be not nullable before, e.g. because it was previously handled, a new warning is triggered:
Code:
let handleString (s: string | null) =
match s with
| null -> 0 // null is already handled here
// FS3262: Value known to be without null passed to a function meant for nullables: You can remove this |Null|NonNull| pattern usage.
| NonNull s -> s.Length // `s` would be sufficient, since it is not null already
A common pattern for handling null arguments is to check them at the beginning of a function, and throw an exception if null is encountered. This might be useful when fitting into existing null-allowing contracts, if enforcing a non-nullable argument is not possible.
Code:
let argValidateShadowing (arg1: string | null) =
let arg1 = nullArgCheck (nameof arg1) arg1
arg1.Length // no warning given, since `arg1` is not nullable anymore
The
nullArgCheck
will throw a System.ArgumentNullException
with the provided string argument (in this case, nameof arg1
was used). The example uses a feature of F# called shadowing. The original arg1
argument is not being mutated, but instead it’s name arg1
is being shadowed and reused for a new value – the non-nullable string
value returned by the nullArgCheck
function.If a dedicated named argument into
nullArgCheck
is not needed, there is a shorthand function called nonNull
which does the same:
Code:
let argValidateShadowing (arg1: string | null) =
let arg1 = nonNull arg1
arg1.Length // no warning given, since `arg1` is not nullable anymore
A new active pattern called
NonNullQuick
can accomplish the same (throwing on null and returning a non-nullable) in pattern matching as well as function argument binding
Code:
let automaticValidationViaActivePattern (NonNullQuick p) = p.X
What does this snippet do? To the outside, it exposes a function of type
RecordField | null
. The RecordField
has been inferred by the usage of p.X
. The nullability annotation has been inferred from the usage of NonNullQuick
. When invoked, this function will immediately check the input argument and throw if it was null. The body of the function then works with p of the non-nullable type RecordField
Eliminate nullness at system boundary
Sometimes a missing value indicated by
null
cannot be handled immediately and possible absence of a value is a valid part of the domain model. Idiomatic F# programs use the option
type for indicating absence of value, and the built-in Option.ofObj
has been extended to support nullable annotations.This function converts from
T | null
to T option
, mapping to None
if the input was null and to Some x
otherwise. The full signature of the function is:
Code:
let inline ofObj (value: 'T | null) : 'T option when 'T: not struct and 'T : not null
It can be used as follows:
let handleString(s: string | null) = Option.ofObj s // returns `string option`
An inverse function called
Option.toObj
exists to create T | null
out of optional values, which might be useful for exposing data to C# consumers.Flow analysis
Using pattern matching, active patterns and library features are the recommended tools to handle
null
values. With the F# 9 release, the compiler does not implement flow-analysis that would be able to derive nullability from branching constructs like if-then-else
or while
. C# does provide it and a single value can change its type depending on a if
condition.In F#,
if
conditionals do not alter the nullability of values in scope, even if the condition does logically handle nulls:if String.IsNullOrEmpty(x) then ...
while not(isNull x) then ...
For recurring checks, we recommend wrapping null-handling constructs into custom reusable active patterns:
Code:
let (|NullOrEmpty|NonEmpty|) s =
match s with
| Null | NonNull "" -> NullOrEmpty
| NonNull s -> NonEmpty s
let getStringLengthSafe s =
match s with
| NullOrEmpty -> 0
| NonEmpty s -> s.Length
There might be situations where a safe solution cannot be proven by the F# compiler, but certain usage of an API makes null impossible. In the following example, the
StreamReader.ReadLine()
method is annotated to return a string | null
. From its documentation we can see that it can only be null
of we reached the EndOfStream
, but the F# compiler has no way to validate this contract. For such cases, an unsafe operation Unchecked.nonNull
exists.It will not do any compile-time nor run-time check, and will only change the type of the value into the non-nullable version (effectively removing
| null
). This operation is dangerous if used incorrectly, as it can lead to returning null
values where no nulls were expected.
Code:
let readAllLines (sr:System.IO.StreamReader) =
seq{
while not sr.EndOfStream do
yield sr.ReadLine() |> Unchecked.nonNull}
?. and ?? operators
Users of C# are familiar with the
?.
and ??
operators for null propagation. At the moment, F# has no direct equivalent of the operators built-in. Potential addition of similar operators into F# is currently discussed as a language suggestion.Type tests and unboxing
Type test, downcast and unboxing are operations whose result cannot be statically determined at compile-time, they rely on runtime behavior of a program. Nullable Reference Types do not alter the runtime representation of a type, and runtime is not aware of type nullability.
Unboxing into nullable versions of types is not possible, because the runtime value of
null
would not succeed during the “is instance of X” instruction. The F# Compiler can only unbox the null
value into types which use null
as their inner representation, most notably the None
case of the option
type.It is possible to use nullable annotation for runtime type tests (and downcasts and unboxing) for inner type parameters of a type. In that case, the compiler allows either version, and it is up to the programmer to validate this being correct. For example in pattern matching,
:? List<string>
and :? List<string | null>
are both possible and both will succeed if the matched instance is a list. However, the compiler does not guarantee that each of the inner elements of that list are actually non-nullable.For safely extracting non-nullable elements of a list of nullable types (and skipping all
null
items), an additional check should be done:
Code:
let extractNonNullables (l:List<string | null>) = l |> List.choose Option.ofObj
// returns a `string list`
Nullable type inference
An aspect which F# programmers love is automatic type inference. In many typical F#-coding situations, users do not have to specify types – of bindings, of function parameters or their return values. As most other languages from the ML family, F# also uses the Hindley-Milner type inference.
The design goal of Nullable Reference Types with respect to type inference was to smoothly support non-nullable codebases. If an existing F# program avoided nulls and handled them at program boundaries by pattern matching or conversion to
option
, it should not need to manually add explicit type annotations.Nullability is automatically inferred for return values of consumed APIs, as well as
let mutable x = null
assignment and usage of T | null
annotated library functions.
Code:
module TypeInference =
// type of `x` as well as the return value are `(string list)| null`. Nullness is inferred from the initial `x = null` assignment
let getList() =
let mutable x = null
x <- ["a";"b"]
x
// Signature is `(int list)| null -> int list`. Parameter nullness is inferred from `nullArgCheck function`
// Return type is not-nullable, because body of the function handles nullness
let processNullableList l =
let l = nullArgCheck (nameof l) l
l |> List.map (fun x -> x + 1)
// Inferred to be `(string | null) -> int`. Parameter `s` is inferred nullable via the `(Null|NonNull)` active pattern usage
let processNullString s =
match s with
| Null -> 0
| NonNull s -> String.length s
For function arguments, F# keeps a null-avoiding stance. In F# as well as in C#, a null-annotated parameter means that
null
is allowed, but non-nullable values can be passed in as well. Unless a value is explicitly annotated as | null
, F# will try to infer the type of values used as arguments into null-allowing APIs as strictly non-nullable. This design avoids flooding in nullness via a sequence of calls which were previously non-nullable.
Code:
let fExists(fileName) = System.IO.File.Exists(fileName)
// Inferred as `fileName: string`, even though File.Exists allows `null`.
// Explicit annotation to `fileName: string | null` or even `fileName: _ | null` is possible.
Inference in generic code
When the syntactical construct
_ | null
is used in generic code, the generic type parameter for _
is inferred to be not null
and not struct
– i.e. only non-nullable reference types allow the | null
modifier.This is different from C# where
T?
can be used in generic code without requiring reference types. In C#, such generic code ignores any nullability when the type parameter is instantiated with a value type, e.g. when T
becomes int
. F# makes the difference explicit and does not allow mixing nullness into value types via generic code. F#’s option
type is recommended for generic code which needs to communicate a possible absence of value.Tooling additions and interoperability scenarios
F# does not support flow analysis and will not be able to eliminate nullness for APIs in
if
/while
based on conditional checks. C# uses the nullable analysis attributes to communicate the relation between a boolean result and the nullability of either an argument or a return value. E.g. for if(!String.IsNullOrEmpty(s){..body of code..}
– if the return value of IsNullOrEmpty
is false
, the input argument can be treated as a non-nullable string in the body of the if
. F# tooling in Visual Studio has been changed to show these attribute-based annotations in method tooltips, to indicate the API contract to the user.F# however does try to generate IL code with the mentioned attributes and make the life easier for C# users consuming F#-defined types. Most notably, this affects
[<Struct>]
discriminated unions with fields, incl. the ValueOption
type defined in FSharp.Core. Such types are represented as a single value type with all fields combined at runtime, and a set of accessor methods. With <Nullable>
turned on, F# will emit additional attributes linking together nullness/non-nullness of those fields with the boolean case testers (.IsA
,.IsB
,…).Conclusion and guidelines
F# 9 brings in the optional
<Nullable>enable</Nullable>
project setting. This adds the | null
nullable type annotation into F#’s type system, allowing to tell nullable and non-nullable reference types apart at compile-time. The recommended usage is to enable this feature and eliminate incoming null
values at project boundaries, either by handling them directly or by converting them into option
or domain specific union types.Handling nulls has the best support in pattern matching, active patterns (
Null|NonNull x|
) and built-in library functions (nullArgCheck
,nonNull
). F# does not have nullable-flow-analysis support for if/then/else
and while
branching constructs. Rewriting the code to use either patterns or helper functions might be needed.Migrating codebases
We are considering a follow-up blog post related to practical aspects of a project’s migration. Are you interested? Do you have a publicly available project which you want to migrate and are willing to share your experience with others via this blog? Please let us know in the comments.
Thanks and acknowledgements
F# is developed as a collaboration between the .NET Foundation, the F# Software Foundation, their members and other contributors including Microsoft. The F# community is involved at all stages of innovation, design, implementation and delivery, and we’re proud to be a contributing part of this community.
- Nullness language suggestion
- Nullness feature discussion
- Nullness RFC (RFC stands for Request For Comments, a detailed specification after a feature has been approved in principle)
What’s next?
The feature is being released as part of the .NET 9 SDK under an optional project switch
<Nullable>enable</Nullable>
. We plan on to process user reports, improve upon the feature and fix any outstanding bugs.Did you encounter an issue? Please submit it dotnet/fsharp. All issues related to Nullable Reference Types can be found under the Area-Nullness label.
We want to step into the design of
?.
and ??
operators for F#, in theory not limiting those to T | null
types. Potential design might increase the scope to also support System.Nullable
, option
and voption
types as well – all being “containers” indicating a possible absence of value.We continue working on F#: be it the language itself, compiler performance, Visual Studio features and improvements and many other aspects of F#.
- See the overall work tracking issues
- Do you want to help? There is a “help wanted” issues list
- Getting started with the compiler codebase? We have a curated list of good first issues
- If you want to stay up-to-date with F#, follow @fsharponline on social media – LinkedIn, X and Hachyderm.
The post Nullable Reference Types in F# 9 appeared first on .NET Blog.
Continue reading...