Logo F# Compiler Guide

Guide to Writing SRTP Code in F#

This guide documents best practices for using Statically Resolved Type Parameters (SRTP) in F#, including the new extension constraint solutions feature (RFC FS-1043).

What Are SRTPs?

Statically Resolved Type Parameters (SRTPs) allow you to write generic code that requires types to have specific members, resolved at compile time:

let inline add (x: ^T) (y: ^T) = x + y

The ^T syntax (hat-type) declares a statically resolved type parameter. The use of (+) generates a member constraint that the compiler resolves at each call site based on the concrete type.

Extension Constraint Solutions (Preview)

With --langversion:preview, extension methods now participate in SRTP constraint resolution.

Defining Extension Operators

open System

type String with
    static member (*) (s: string, n: int) = String.replicate n s

let inline multiply (x: ^T) (n: int) = x * n
let result = multiply "ha" 3  // "hahaha"

Tuple Type Extensions

Type extensions can be written directly on tuple types using tuple syntax. The tuple type is rewritten to its underlying named type — System.Tuple<...> for reference tuples and System.ValueTuple<...> for struct tuples:

// Reference tuple: rewritten to System.Tuple<'T1, 'T2>
type ('T1 * 'T2) with
    static member inline (<*>) ((a, f), (b, x)) = (a, f x)

let r = (1, string) <*> (2, 3)   // (1, "3")

// Struct tuple: rewritten to System.ValueTuple<'T1, 'T2>
type struct ('T1 * 'T2) with
    static member Fst (struct (a, _b)) = a

This capability is gated behind the same preview flag as extension constraint solutions (see Feature Flag); below preview it is rejected with a feature-availability error.

Only static members and operators are supported on a tuple type extension. An instance member (member x.Foo = ...) can be declared but cannot be invoked through dot-notation on a tuple value — resolution fails with error FS0039: The field, constructor or member 'Foo' is not defined. Use a static member or operator (as above) instead.

Resolution Priority

When solving an SRTP constraint: 1. Built-in solutions for primitive operators (e.g. int + int) are applied when the types match precisely, regardless of whether extension methods exist 2. Overload resolution considers both intrinsic and extension members. Extension members are lower priority in the resolution order (same as in regular F# overload resolution) 3. Among extensions, standard F# name resolution rules apply (later open shadows earlier)

Accessibility

An SRTP constraint may only be solved by a public member. A private, internal, or protected member is never a valid witness — even one that is visible at the point where the inline function is defined. The inline function carries its solution to arbitrary call sites, so a non-public witness would be inlined into scopes where it is inaccessible (a runtime MethodAccessException). The compiler therefore rejects it at definition time:

module A =
    type System.Int32 with
        static member private Secret (x: int) = x + 1
    // error FS0001: ... 'Secret' ... is not public
    let inline useSecret (x: ^T) = (^T : (static member Secret: ^T -> ^T) x)

This applies to every witness kind — named methods, operators, property accessors, and op_Implicit/op_Explicit conversions — and holds across assembly boundaries: an internal member is not a valid witness in a referencing assembly even with InternalsVisibleTo.

Scope Capture

With --langversion:preview, extrinsic extension members (defined on a type from another assembly) participate in SRTP constraint resolution when they are in scope where the inline function is defined:

module StringOps =
    // System.String has no built-in (*) — this extension is extrinsic.
    type System.String with
        static member (*) (s: string, n: int) = System.String.Concat(Array.replicate n s)

module GenericLib =
    open StringOps

    // multiply captures the SRTP constraint with String.(*) in scope.
    // The extension is recorded in the constraint at this definition site.
    let inline multiply (x: ^T) (n: int) = x * n

module Consumer =
    open GenericLib
    // StringOps is NOT opened here, but the extension was captured when
    // multiply was defined. It travels with the constraint and resolves here.
    let r = multiply "ha" 3  // "hahaha"

Definition-site capture is intra-assembly only. The capture shown above travels with the constraint within a single assembly. It is deliberately not serialized into compiled metadata (see Binary compatibility), so when multiply lives in a referenced assembly the captured StringOps extension does not travel to the consumer. Cross-assembly, SRTP constraints are resolved from the consumer's scope: the consumer must have the extension in scope (e.g. open StringOps / open type) at its own call site.

// GenericLib compiled into library.dll (opens StringOps at its definition site)
// Consumer.fs references library.dll:
open GenericLib
// error FS0001: None of the types support the operator '*'
//   — StringOps is not in scope here, and cross-assembly capture is not serialized.
let r = multiply "ha" 3

// Fix: bring the extension into the consumer's scope.
open StringOps
let ok = multiply "ha" 3  // "hahaha"

Known Limitations

Binary Compatibility

Extension solutions captured during constraint solving are not written into compiled metadata. A trait constraint's set of candidate extension members and its accessor domain live only in-process while a file is being checked; they are discarded before IL/metadata emission, so the on-disk pickle format is unchanged and old and new compilers interoperate.

The practical consequence is the intra- vs cross-assembly split described under Scope Capture: within one assembly an inline function carries its definition-site extensions, but a consumer of a compiled inline function resolves SRTP constraints from its own scope and must have the relevant extensions in scope.

Weak Resolution Changes

With --langversion:preview, inline code no longer eagerly resolves SRTP constraints via weak resolution when true overload resolution is involved:

// Before: f1 inferred as DateTime -> TimeSpan -> DateTime (non-generic, because op_Addition
//         had only one overload and weak resolution eagerly picked it)
// After:  f1 stays generic: DateTime -> ^a -> ^b (because weak resolution no longer forces
//         overload resolution for inline code)
let inline f1 (x: DateTime) y = x + y

Workarounds for Breaking Changes

If existing inline code breaks:

  1. Add explicit type annotations:

    let inline f1 (x: DateTime) (y: TimeSpan) : DateTime = x + y
    
  2. Use sequentialization to force resolution order

  3. Sequentialize nested calls when using FSharpPlus-style patterns with return types in support types. If nesting InvokeMap calls directly produces errors, sequentialize with a let-binding (see the sequentialization example above). Do NOT remove return types from support types unless you understand the impact on overload resolution — return types are the fundamental mechanism for return-type-driven resolution in type-class encodings.

Feature Flag

Enable with: --langversion:preview
Feature name: ExtensionConstraintSolutions

This feature is gated at the preview language version and will be stabilized in a future F# release.

AllowOverloadOnReturnType Attribute

The [<AllowOverloadOnReturnType>] attribute (in FSharp.Core) enables return-type-based overload resolution for any method, extending behavior previously reserved for op_Explicit and op_Implicit:

type Converter =
    [<AllowOverloadOnReturnType>]
    static member Convert(x: string) : int = int x
    [<AllowOverloadOnReturnType>]
    static member Convert(x: string) : float = float x

let resultInt: int = Converter.Convert("42")       // resolves to int overload
let resultFloat: float = Converter.Convert("42")   // resolves to float overload

Without the attribute, these overloads would produce an ambiguity error. Note that the call site must provide enough type context (e.g., a type annotation) for the compiler to select the correct overload.

Design Intent: Aspirational Patterns

⚠️ MOSTLY ASPIRATIONAL: The patterns below are taken from the RFC to illustrate the long-term design intent. Except where a subsection explicitly shows a working example, they do not compile with the current implementation. Cross-type operator extensions (e.g., float + int) interact with built-in operator resolution in complex ways that are not yet supported. Do not rely on the aspirational snippets in production code.

Numeric Widening via Extension Operators (NOT IMPLEMENTED)

The RFC describes retrofitting widening conversions onto primitive types:

// ⚠️ ASPIRATIONAL — does not compile
type System.Int32 with
    static member inline widen_to_double (a: int32) : double = double a

let inline widen_to_double (x: ^T) : double = (^T : (static member widen_to_double : ^T -> double) (x))

type System.Double with
    static member inline (+)(a: double, b: 'T) : double = a + widen_to_double b
    static member inline (+)(a: 'T, b: double) : double = widen_to_double a + b

Warning: Defining (+) extensions on System.Double would shadow built-in arithmetic for all float operations in scope. This pattern requires careful design to avoid degrading error messages and performance for existing code.

Defining op_Implicit via Extension Members

Public extension op_Implicit/op_Explicit conversions do participate in SRTP resolution when the target type is determined at the call site:

module A =
    type Wrap = { X: int }
    type Wrap with
        static member op_Implicit (w: Wrap) : int = w.X
    let inline conv (x: ^T) : int = ((^T) : (static member op_Implicit : ^T -> int) x)

let r = A.conv { A.Wrap.X = 5 }  // 5

What is not supported is the return-type-polymorphic form the RFC describes — a single (^T or ^U) conversion function backed by several op_Implicit overloads that differ only by return type:

// ⚠️ ASPIRATIONAL — does not compile (error FS0001: None of the types support the operator 'op_Implicit')
let inline implicitConv (x: ^T) : ^U = ((^T or ^U) : (static member op_Implicit : ^T -> ^U) (x))

type System.Int32 with
    static member inline op_Implicit (a: int32) : int64 = int64 a
    static member inline op_Implicit (a: int32) : double = double a

Note: Even where supported, these conversions are explicit in F# code (you must call the conversion function), not implicit as in C#.

val add: x: 'T -> y: 'T -> 'a (requires member (+))
val x: 'T (requires member (+))
'T
val y: 'T (requires member (+))
namespace System
Multiple items
type String = interface seq<char> interface IEnumerable interface ICloneable interface IComparable interface IComparable<string> interface IConvertible interface IEquatable<string> interface IParsable<string> interface ISpanParsable<string> new: value: nativeptr<char> -> unit + 8 overloads ...
<summary>Represents text as a sequence of UTF-16 code units.</summary>

--------------------
String(value: nativeptr<char>) : String
String(value: char array) : String
String(value: ReadOnlySpan<char>) : String
String(value: nativeptr<sbyte>) : String
String(c: char, count: int) : String
String(value: nativeptr<char>, startIndex: int, length: int) : String
String(value: char array, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: Text.Encoding) : String
val s: string
Multiple items
val string: value: 'T -> string

--------------------
type string = String
val n: int
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val replicate: count: int -> str: string -> string
val multiply: x: int -> n: int -> int
val x: int
val result: int
type Int32 = member CompareTo: value: int -> int + 1 overload member Equals: obj: int -> bool + 1 overload member GetHashCode: unit -> int member GetTypeCode: unit -> TypeCode member ToString: unit -> string + 3 overloads member TryFormat: utf8Destination: Span<byte> * bytesWritten: byref<int> * ?format: ReadOnlySpan<char> * ?provider: IFormatProvider -> bool + 1 overload static member Abs: value: int -> int static member BigMul: left: int * right: int -> int64 static member Clamp: value: int * min: int * max: int -> int static member CopySign: value: int * sign: int -> int ...
<summary>Represents a 32-bit signed integer.</summary>
String.Concat<'T>(values: 'T seq) : string
   (+0 other overloads)
String.Concat(values: ReadOnlySpan<string>) : string
   (+0 other overloads)
String.Concat( values: string array) : string
   (+0 other overloads)
String.Concat(args: ReadOnlySpan<obj>) : string
   (+0 other overloads)
String.Concat( args: obj array) : string
   (+0 other overloads)
String.Concat(arg0: obj) : string
   (+0 other overloads)
String.Concat(values: string seq) : string
   (+0 other overloads)
String.Concat(str0: string, str1: string) : string
   (+0 other overloads)
String.Concat(str0: ReadOnlySpan<char>, str1: ReadOnlySpan<char>) : string
   (+0 other overloads)
String.Concat(arg0: obj, arg1: obj) : string
   (+0 other overloads)
type Array = interface ICollection interface IEnumerable interface IList interface IStructuralComparable interface IStructuralEquatable interface ICloneable member Clone: unit -> obj member CopyTo: array: Array * index: int -> unit + 1 overload member GetEnumerator: unit -> IEnumerator member GetLength: dimension: int -> int ...
<summary>Provides methods for creating, manipulating, searching, and sorting arrays, thereby serving as the base class for all arrays in the common language runtime.</summary>
val replicate: count: int -> initial: 'T -> 'T array
Multiple items
type DateTime = new: date: DateOnly * time: TimeOnly -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>

--------------------
DateTime ()
   (+0 other overloads)
DateTime(ticks: int64) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly) : DateTime
   (+0 other overloads)
DateTime(ticks: int64, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
Multiple items
type TimeSpan = new: hours: int * minutes: int * seconds: int -> unit + 4 overloads member Add: ts: TimeSpan -> TimeSpan member CompareTo: value: obj -> int + 1 overload member Divide: divisor: float -> TimeSpan + 1 overload member Duration: unit -> TimeSpan member Equals: value: obj -> bool + 2 overloads member GetHashCode: unit -> int member Multiply: factor: float -> TimeSpan member Negate: unit -> TimeSpan member Subtract: ts: TimeSpan -> TimeSpan ...
<summary>Represents a time interval.</summary>

--------------------
TimeSpan ()
TimeSpan(ticks: int64) : TimeSpan
TimeSpan(hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int, microseconds: int) : TimeSpan
type Converter<'TInput,'TOutput (allows ref struct and allows ref struct)> = new: object: obj * method: nativeint -> unit member BeginInvoke: input: 'TInput * callback: AsyncCallback * object: obj -> IAsyncResult member EndInvoke: result: IAsyncResult -> 'TOutput member Invoke: input: 'TInput -> 'TOutput
<summary>Represents a method that converts an object from one type to another type.</summary>
<param name="input">The object to convert.</param>
<typeparam name="TInput">The type of object that is to be converted.</typeparam>
<typeparam name="TOutput">The type the input object is to be converted to.</typeparam>
<returns>The <typeparamref name="TOutput" /> that represents the converted <typeparamref name="TInput" />.</returns>
type Convert = static member ChangeType: value: obj * conversionType: Type -> obj + 3 overloads static member FromBase64CharArray: inArray: char array * offset: int * length: int -> byte array static member FromBase64String: s: string -> byte array static member FromHexString: utf8Source: ReadOnlySpan<byte> -> byte array + 5 overloads static member GetTypeCode: value: obj -> TypeCode static member IsDBNull: value: obj -> bool static member ToBase64CharArray: inArray: byte array * offsetIn: int * length: int * outArray: char array * offsetOut: int -> int + 1 overload static member ToBase64String: inArray: byte array -> string + 4 overloads static member ToBoolean: value: bool -> bool + 17 overloads static member ToByte: value: bool -> byte + 18 overloads ...
<summary>Converts a base data type to another base data type.</summary>
Multiple items
val float: value: 'T -> float (requires member op_Explicit)

--------------------
type float = Double

--------------------
type float<'Measure> = float
Multiple items
val int32: value: 'T -> int32 (requires member op_Explicit)

--------------------
type int32 = Int32

--------------------
type int32<'Measure> = int<'Measure>
Multiple items
val double: value: 'T -> double (requires member op_Explicit)

--------------------
type double = Double

--------------------
type double<'Measure> = float<'Measure>
type Double = member CompareTo: value: float -> int + 1 overload member Equals: obj: float -> bool + 1 overload member GetHashCode: unit -> int member GetTypeCode: unit -> TypeCode member ToString: unit -> string + 3 overloads member TryFormat: utf8Destination: Span<byte> * bytesWritten: byref<int> * ?format: ReadOnlySpan<char> * ?provider: IFormatProvider -> bool + 1 overload static member (<) : left: float * right: float -> bool static member (<=) : left: float * right: float -> bool static member (<>) : left: float * right: float -> bool static member (=) : left: float * right: float -> bool ...
<summary>Represents a double-precision floating-point number.</summary>
Multiple items
val int64: value: 'T -> int64 (requires member op_Explicit)

--------------------
type int64 = Int64

--------------------
type int64<'Measure> = int64

Type something to start searching.