fsharp-library-api-design
Skill23 starsChanged 32 days agoMITView on GitHub
Design and change the public API of an F# library without breaking its consumers. Covers F#-specific breaking changes (new union cases, new record fields, curried and tupled forms, generic and inline functions, RequireQualifiedAccess, renamed modules), binary breaks that a clean build does not show (a new optional parameter, a wider parameter type, cross-assembly inlining of a small function body into Release consumers, --nooptimizationdata), safe extension patterns (overloads, new functions), a C#-friendly surface, SemVer for each change type, the lowest FSharp.Core version, and compatibility checks with package validation and old consumers. Use when the user adds to or changes a published F# library, asks whether a change is breaking, picks a version number, or exposes F# code to C#.
In this repository16 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- fsharp-async-compositionSkill
- fsharp-binary-compatibilitySkill
- fsharp-env-capabilitiesSkill
- fsharp-failure-pathsSkill
- fsharp-idiom-reviewSkill
- fsharp-iwsam-errorsSkill
- fsharp-native-aotSkill
- fsharp-nullness-interopSkill
- fsharp-performance-typesSkill
- fsharp-serializationSkill
- fsharp-signature-filesSkill
- fsharp-verify-apiSkill
- optimize-fsharp-typecheck-graphSkill
- parse-dont-validateSkill
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 5 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 42
Read references/CHANGES.md for each change with its code and its safe alternative.
- Very specific: line 51
Interfaces: do not add a member to a public interface.
- Distinctive: line 52
Curried and tupled: keep the form of each public function.
- Very specific: line 67
Do not use F#-only types in parameters: option, voption, list, Map, Set, function types, and Async.
- Very specific: line 70
Use [<CompiledName("PascalCase")>] on a new module function that C# calls.
- Distinctive: line 75
Use SemVer for each change type:
- Very specific: line 95
Before the change, record the public surface of the built library.
- Distinctive: line 107
Check both contracts with the consumers that you have:
- Distinctive: line 115
Run the proof runner from the skill root:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
F# library API design
A published library has two contracts:
- Source compatibility: F# and C# code that compiled against 1.0.0 compiles against 1.1.0.
- Binary compatibility: an assembly that was compiled against 1.0.0 runs with 1.1.0. NuGet gives this case often. When package A was built against 1.0.0 and the app needs 1.1.0, A runs with 1.1.0 and is not compiled again.
A minor or patch version must keep both contracts. A new overload on a type member is the one exception when it makes no existing call ambiguous. It breaks a C# method group with no delegate type. A clean build of every consumer that you can see proves only source compatibility. Many F# breaking changes do not look like breaking changes. Some are source-only, and package validation does not report them.
Changes and their results
Each row is a change to version 1.0.0 that scripts/run.ps1 proves. "Source" rebuilds an F# and a C# consumer with TreatWarningsAsErrors. FS0025, FS0044, and CS0618 are warnings that this setting makes errors. "Binary" runs a consumer that was compiled against 1.0.0 with the new DLL.
| Change | F# source | C# source | Binary | Package validation |
| New union case | FS0025 | ok | ok until a new case reaches old code | not reported |
| New record field | FS0764 | CS7036 | MissingMethodException | CP0002 |
| Curried function made tupled | FS0001 | ok | ok | not reported |
[<RequireQualifiedAccess>] added | FS0039, and FS0025 in a match | ok | ok | not reported |
inline body changed, with or without --nooptimizationdata | ok | ok | old body runs in F# until rebuild | not reported |
| Small function body changed | ok | ok | old body runs in F# until rebuild | not reported |
Small function body changed, --nooptimizationdata in 1.0.0 and 1.1.0 | ok | ok | new body runs | not reported |
Parameter made generic (int list to 'T list) | ok | ok | MissingMethodException | CP0002 |
Parameter type widened (int list to seq<int>) | ok | ok | MissingMethodException | CP0002 |
[<Optional; DefaultParameterValue>] parameter added to an existing member | ok | ok | MissingMethodException | CP0002 |
F# ?x parameter added to an existing member | ok | CS7036 | MissingMethodException | CP0002 |
| Member added to a public interface | FS0366 | CS0535 | TypeLoadException | CP0006 |
| Module renamed | FS0039 | CS0103 | TypeLoadException | CP0001 |
[<CompiledName>] added to an existing function | ok | CS0117 | MissingMethodException, except in a Release F# consumer that used cross-assembly inlining of a small body | CP0002 |
[<Obsolete>] on a function | FS0044 | CS0618 | ok | not reported |
New overload on a type member (seq<int> beside int list, or one more parameter) | ok | CS8917 for a method group such as var total = client.Total; | ok | ok |
New overload with an unrelated reference type (Send(System.Uri) beside Send(string)) | FS0041 for an unannotated function parameter | CS0121 for client.Send(null) | ok | ok |
Read references/CHANGES.md for each change with its code and its safe alternative.
Rules
- Extend. Do not change. Do not change the signature of a public function or member in a minor version. This includes a new optional parameter and a wider or generic parameter type. Add an overload on a type member, or add a new function next to the old one.
- Check overload resolution. A new overload must not make an existing call ambiguous. When neither parameter type is a subtype of the other, as with
Uriandstring, a call that both accept is ambiguous. C#client.Send(null)then fails with CS0121. An F# call fails with FS0041 when the argument type is not known before the call, such as an unannotated function parameter. Use a new member name instead, or test these call shapes in an F# and a C# consumer, includingnulland a numeric literal. - Overloads need a type. A module function cannot have an overload. To give a module function a new parameter, add a function with a new name. Mark the old one
[<Obsolete("Use X.")>]only in a major version. A consumer withTreatWarningsAsErrorsfails with FS0044 or CS0618. - Records: a new field removes the old constructor and breaks each consumer that builds the record, including test code. When a record must grow, plan for it in the first version: expose a
createfunction or a class with members. Use fsharp-signature-files to hide a representation. - Unions: a new case is a breaking change for each consumer that matches on the union. An old binary that gets the new case can run a wrong branch of its match, with no error. A rebuilt consumer that builds with warning FS0025 throws
MatchFailureExceptionon the new case. fsharp-binary-compatibility proves both results (union-new-case). Release a new case in a major version, or hide the representation before the first release. - Interfaces: do not add a member to a public interface. Add a new interface that inherits the old one, for example
IStore2, and test for it at run time with:?. - Curried and tupled: keep the form of each public function. A change compiles to the same method, so package validation does not see it, but F# callers fail.
inline: aninlinebody is compiled into each caller. A fix in the body reaches a consumer only when the consumer builds again, also with--nooptimizationdata. Useinlineon a public function only when it needs statically resolved type parameters.Small function bodies: Release builds also copy small non-
inlinefunctions through cross-assembly inlining. The F# compiler puts optimization data in the library DLL, and an F# consumer that is built in Release copies small public function bodies from it. An old consumer then keeps the old body. C# consumers do not copy bodies. A library that must take body fixes with no consumer rebuild needs--nooptimizationdatafrom its first release:<OtherFlags>$(OtherFlags) --nooptimizationdata</OtherFlags>The flag does not change consumers that were compiled against an earlier DLL that had optimization data. Without cross-assembly inlining, consumer code can be slower.
inlinefunctions are still copied with the flag. Decide this before the first release.- Names: a renamed module, type, or function and a new
[<RequireQualifiedAccess>]break callers. Add the new name and keep the old one.
C#-friendly surface
Apply these rules to new public members that C# can call:
- Do not use F#-only types in parameters:
option,voption,list,Map,Set, function types, andAsync. C# must then build these values with FSharp.Core. Useseq<'T>, arrays,IReadOnlyList<'T>, delegates such asFunc<'T, 'U>, andnullor an overload for an absent value. - Do not return
option,voption, function types, orAsyncto C#. For an absent result, returnnull, or use aTryXmethod with anoutrefparameter. Return a delegate instead of an F# function andTask<'T>instead ofAsync<'T>. A returnedlist,Map, orSetworks in C# as a .NET collection, butIReadOnlyList<'T>or an array is easier to use. - An F#
?xparameter is anFSharpOption<T>parameter in C#, and C# must pass it. For a parameter that C# can leave out, use[<Optional; DefaultParameterValue(...)>]on a new member only. - Use
[<CompiledName("PascalCase")>]on a new module function that C# calls. Do not addCompiledNameto an existing function, because it renames the compiled method. - Return
Task<'T>at the boundary. KeepAsync<'T>for F# callers in a separate function or module.
Versioning
Use SemVer for each change type:
| Version | Allowed changes |
| Major | Any break in the table above, in F# source, C# source, or binary form. This includes a new [<Obsolete>], because a consumer with TreatWarningsAsErrors fails. A new overload that makes no existing call ambiguous is the exception: it stays minor, and the release notes name it. |
| Minor | New types, new functions, new overloads that make no existing call ambiguous, and new members on classes. |
| Patch | Fixes that do not change the public surface. A fix to an inline body, or to a small function body without --nooptimizationdata, reaches F# callers only when they build again. Say so in the release notes. |
Set the lowest FSharp.Core version that the library needs. By default, the package depends on the FSharp.Core version of the SDK (10.1.403 with SDK 10.0.403), and each consumer must then use that version or a later one. The proof pins FSharp.Core 6.0.7 in a netstandard2.0 library. An older FSharp.Core can lack functions that the code uses, so build the library with the pin:
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="6.0.7" />
</ItemGroup>A higher minimum FSharp.Core forces consumers to upgrade. Name each increase in the release notes.
Steps
- Before the change, record the public surface of the built library. If fsharp-signature-files is installed, use its
public-surface.ps1. Keep the output as a snapshot file. Without that script, use package validation in step 3 as the surface check. - List each public change that the request needs. Find its row in the table. If a row breaks a contract, use the safe alternative from references/CHANGES.md, or make the version major.
Turn on package validation against the last released version and pack:
<PropertyGroup> <EnablePackageValidation>true</EnablePackageValidation> <PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion> </PropertyGroup>dotnet packthen fails with CP0001, CP0002, or CP0006 when a type or member signature of 1.0.0 is gone or changed. It does not report F#-only source breaks, a new union case, or a changed function body.- Check both contracts with the consumers that you have:
- Source: build each F# and C# consumer, with its tests, against the new library. For a new overload, also build the call shapes from the overload rule above.
- Binary: build each consumer against the old version. Copy the new library DLL, and each new dependency DLL, into its output folder. Run the consumer. The output must not change.
- Compare the public surface with the snapshot. Each removed or changed line is a break. Each added line that C# calls must follow the C#-friendly rules above.
- Set the version and write a release notes entry that names each addition, each
[<Obsolete>], and each FSharp.Core change.
Verify
Run the proof runner from the skill root:
pwsh ./scripts/run.ps1The runner packs scripts/V1 as the 1.0.0 baseline. For each folder in scripts/changes, it packs the changed library with package validation. It builds the F# and C# consumers against it, and runs the 1.0.0 consumers with the new DLL. A // flags: line in a change gives compiler flags to the 1.0.0 and the changed library. It compares each result with the // expect- lines of the change and exits with code 1 on a difference. It also checks the FSharp.Core dependency of a package with and without the pinned version.
These rules are not bundled proofs: the wrong branch and the MatchFailureException for a new union case come from the union-new-case proof of fsharp-binary-compatibility, and F# cannot declare a default implementation for an interface member. The IStore2 sample in references/CHANGES.md is not compiled by the runner. The runner gives a // flags: line to both versions, so it does not prove that a flag only in 1.1.0 leaves old consumers with the body that cross-assembly inlining copied.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

