fsharp-binary-compatibility
Skill23 starsChanged 32 days agoMITView on GitHub
Change a published F# library so that apps compiled against the old version still run against the new DLL with no rebuild. Covers new parameters on let-bound functions (no overloads), optional member parameters, inferred type changes, union case order and new cases, record fields, inline and [<Literal>] values, cross-assembly inlining (--nooptimizationdata), renames with CompiledName, curried and tupled parameters, abstract interface members, the FSharp.Core dependency, and automated checks with package validation, apicompat, and an F# signature diff. Use when the user changes the public API of an F# library or NuGet package in a minor or patch release, asks for a non-breaking change, or asks whether an F# change is binary compatible.
In this repository16 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- fsharp-async-compositionSkill
- fsharp-env-capabilitiesSkill
- fsharp-failure-pathsSkill
- fsharp-idiom-reviewSkill
- fsharp-iwsam-errorsSkill
- fsharp-library-api-designSkill
- 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
4 rules. About 0 are common, 4 are distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 110
Read references/RULES.md for each break with its safe form and its measured result.
- Distinctive: line 115
Keep the baseline: Before the change, find the released version: a git tag, a release folder, or the published package.
- Distinctive: line 181
Read references/TOOLS.md for the measured result of each tool on each proof case.
- Distinctive: line 186
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# binary compatibility
A change is binary compatible when an app that was compiled against v1 still runs, with the same results, after you replace the library DLL with v2. The app is not rebuilt. A successful build of the solution proves only source compatibility, because the build compiles every consumer again.
F# adds traps that C# does not have. A let function cannot have overloads. The
compiler copies some library code into each consumer. A union match uses case
numbers. The rules below come from the proof runner in scripts/. Each rule
names its proof case in scripts/proof/cases/.
To stop type inference from changing a signature, use fsharp-signature-files. This skill does not repeat it.
Rules
Do not add a parameter to a public
letfunction. The compiled static method gets a new signature, and an old app getsMissingMethodException. Add a new function and keep the old one as a wrapper (module-parameter):let parsePriceIn (currency: string) (text: string) = ... let parsePrice (text: string) = parsePriceIn "USD" textA rebuilt caller can still compile after the break:
parsePrice textbecomes a partial application, and%Aprints a function value.- On a type member, add an overload. A new optional parameter (
?currency) keeps source compatibility, but it replaces the old method, and an old app getsMissingMethodException(member-optional). Keep the old member and add a secondstatic memberor method with the new parameter. - Write explicit types on each public function, or use a
.fsifile. A body change can change the inferred signature, for exampleintto'a, orseqtolist. Each one givesMissingMethodException(infer-generic,infer-collection). - Add a union case only at the end. A case in the middle changes the tag number
of each later case. An old app then runs the wrong match branch, with no error
(
union-insert). Package validation andapicompatdo not report it. A small union with case fields kept working in the proof (union-insert-small), but do not depend on that. - Do not return a new union case to an old app. An old app that gets an unknown
case runs a wrong branch of its match, with no error. A rebuilt app gets warning
FS0025 and
MatchFailureException(union-new-case). When an old app can get the new case from an existing function, for example because it already passes the new input, keep that function on the old cases and add a new function that can return the new case. Otherwise, make a major version. An existing function can return the new case only when no old app can get it. - Do not add a field to a public record that callers construct. Record
construction and copy-and-update (
{ r with ... }) call the record constructor, and an old app getsMissingMethodException(record-field,record-copy). A record cannot keep its old constructor:newin a record gives FS0871. Put the new data in a new type, for example{ Line: OrderLine; Note: string option }, with new functions. - Treat
inlinebodies and[<Literal>]values as copied into each app. An old app keeps the old body or value until it is rebuilt (inline-body,literal-value). Use a plain function and a plainletvalue when the body or value can change, and build the library with the rule 8 flag. With default flags, a Release app also copies a plainletvalue and keeps the old value (literal-value,plain-default). Know that Release builds also copy small non-inline functions. The F# compiler puts optimization data in the library DLL, and a consumer that is built in Release copies small public function bodies from it. An old app then keeps the old body (
body-inlined). A copied body can also call a changed member: an app that only called a smalllinefactory gotMissingMethodExceptionfor the record constructor (record-inlined). A library that must take body changes with no consumer rebuild needs--nooptimizationdatafrom its first release:<OtherFlags>$(OtherFlags) --nooptimizationdata</OtherFlags>The flag does not change apps that were compiled against an earlier DLL that had optimization data (
body-inlined,safe from v1). It also stops cross-assembly inlining, so consumer code can be slower.inlinefunctions are still copied with the flag.- Rename with a wrapper. In a minor release, add the new name and keep the old
name as a plain wrapper (
rename). Mark the old name[<Obsolete("Use X.")>]only in a major version, and name it in the release notes. A new[<Obsolete>]on an existing member is a source break: a rebuilt caller gets warning FS0044, and a consumer withTreatWarningsAsErrorsfails (rename, variantobsolete). Old binaries still run.[<CompiledName("oldName")>]on the renamed function keeps old apps working, but each F# caller that is rebuilt gets FS0039. - Do not change between curried and tupled parameters. Both forms compile to
the same method, so an old app keeps working, but a rebuilt F# caller fails
(
curried-tupled).apicompatdoes not report it. Add a new function instead. Do not raise the FSharp.Core dependency in a minor release. By default the package depends on the FSharp.Core version of the SDK that packed it, for example 10.1.403. A consumer that references a lower FSharp.Core gets only warning NU1605. It builds, and then fails at startup with
FileNotFoundExceptionforFSharp.Core, Version=10.1.0.0. Set the lowest version that the library needs:<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference> ... <PackageReference Include="FSharp.Core" Version="8.0.400" />- Do not add an abstract member to a public interface. An old app that
implements the interface gets
TypeLoadException, and a rebuilt one gets FS0366 (interface-member). Add a new interface that inherits the old one, and test for it with:?where you need the new member.
Read references/RULES.md for each break with its safe form and its measured result.
Steps
- Keep the baseline. Before the change, find the released version: a git tag, a release folder, or the published package. Build it and keep its DLL.
- Make the change with the rules above. Done when no public
letfunction has a new parameter, no union case moved, no constructed record has a new field, and no public interface has a new abstract member. Run package validation or
apicompat. Package validation runs indotnet pack:<EnablePackageValidation>true</EnablePackageValidation> <PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion>Restore must find the baseline package on a feed. With package source mapping in a
nuget.config, map the package ID to that feed, or restore gives NU1101. For a baseline that is not packed, compare the DLLs with theapicompattool. The left side is the baseline:dotnet tool install Microsoft.DotNet.ApiCompat.Tool --create-manifest-if-needed dotnet apicompat --left <baseline>/Lib.dll --right <new>/Lib.dllThe skill's
scripts/.config/dotnet-tools.jsonpins version 10.0.403. Rundotnet tool restoreinscripts/to use it from there.Both tools fail with
CP0002for a removed or changed member. Fix eachCP0002.Run the F# signature diff. It shows the changes that the IL tools do not report: union case order, curried and tupled parameters,
[<Literal>]values, and inferred types. From the skill root:pwsh ./scripts/sig-diff.ps1 -Project <path>/Lib.fsproj -BaselineRef v1.0.0 pwsh ./scripts/sig-diff.ps1 -Project <path>/Lib.fsproj -BaselineProject <baseline>/Lib.fsprojExit code 0 means no signature change, 1 means a change (the diff is printed), and 2 means a build or checkout failure. The script builds the current project in place. It checks out
-BaselineRefin a temporary folder next to the repository, so MSBuild, NuGet, andglobal.jsonfiles above the repository apply to both builds. Pass-Framework <tfm>for a multi-target project. Check each changed line against the rules. An added line is safe only when it adds a function, adds a member to a type that old apps do not implement, or adds a union case after the last case.- Run an old app against the new DLL. Build a small consumer against the
baseline DLL in Release. Copy the new library DLL into its output folder, with no
rebuild, and run it. It must give the same output for the old cases, with no
MissingMethodException,TypeLoadException, orMissingFieldException. No tool finds copied bodies or behavior changes. Only this run does. - Rebuild the consumers against the new version and test the new features.
- For an accepted break, make a major version. A new
[<Obsolete>]on an existing member also needs a major version, and the release notes must name it. Record each binary break inCompatibilitySuppressions.xml.dotnet pack -p:ApiCompatGenerateSuppressionFile=truewrites the file next to the project, and later packs read it. Add an XML comment with the reason above each suppression.
Tools
| Tool | Use it | Reports | Does not report |
| Package validation | Yes, in the build | Removed or changed members (CP0002) | Union case order, copied bodies. From the apicompat results, also [<Literal>] values and curried and tupled changes |
apicompat | Yes, for DLLs that are not on a feed | The same as package validation. --strict-mode also reports each added member. | The same as package validation, also with --enable-rule-attributes-must-match |
sig-diff.ps1 | Yes, after package validation | The F# view: case order, curried and tupled, literals, inferred types | Body changes, also in inline functions |
| PublicApiGenerator snapshot | Optional | Each public change, also the union, literal, and curried-to-tupled changes. Also each addition that does not break. | Body changes |
Microsoft.CodeAnalysis.PublicApiAnalyzers | No | Nothing in F#: the F# compiler does not run Roslyn analyzers | Everything |
Read references/TOOLS.md for the measured result of each tool on each proof case.
Verify
Run the proof runner from the skill root:
pwsh ./scripts/run.ps1For each case, the runner builds the library versions and a consumer against the
baseline. It runs the old consumer with the new DLL, rebuilds the consumer, and runs
apicompat, apicompat --strict-mode, the signature diff, and PublicApiGenerator. It
fails when a result differs from the // expect line in the case's Consumer.fs.
It then checks package validation, the FSharp.Core dependency, the Roslyn analyzer
claim, and sig-diff.ps1. Use -Case <name> to run only some cases. With -Case,
the runner does not run these tool proofs.
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.

