A .fsi file is the contract of its .fs file. A value or type that is not in the
.fsi is hidden from every later file and every other assembly. A type that the .fsi
declares with no body (type Email) hides its fields and cases.
The compiler can write a .fsi for you, but that output repeats every public member.
It hides nothing. The work is to remove what callers do not use.
Build time can also improve. Later files type-check against the .fsi, not the
.fs, so graph-based type checking can check them in parallel with the implementation.
Measure this before you claim a speed-up. See
optimize-fsharp-typecheck-graph.
Steps
- Record the public surface before the change. Build the library and run:
pwsh ./scripts/public-surface.ps1 -Assembly <bin>/<Lib>.dll -OutFile before.txt
-
Generate a starting point. Build once with
--allsigs. It writes one.fsinext to each.fs, with doc comments and attributes:dotnet build <Lib>.fsproj -p:OtherFlags=--allsigsBuild the library project file, not the solution. The global property also reaches each project that the library references, and it replaces an
OtherFlagsvalue that the project sets. When the project setsOtherFlags, add--allsigsto that value for one build instead.--sig:<file>writes all signatures into one file, which you must then split by hand. Remove the flag after this build. Delete each.fsithat the build wrote outside the library, and the.fsifiles underobj/for generated files such asAssemblyAttributes.fs. -
Add each
.fsito the project directly before its.fs:<Compile Include="Email.fsi" /> <Compile Include="Email.fs" />A
.fsiafter its.fsgives FS0238 and FS0248. -
Decide what is truly public. Search the other projects, the tests, and the documented entry points for each member in the
.fsi. Delete eachval, type, and member that no other project uses. Do not mark itprivatein the.fsinstead: the omission from the.fsiis the hiding.- A member that only tests use: write
val internal f: ...in the.fsiand add<InternalsVisibleTo Include="<Tests assembly name>" />to the library project. Do not make it public for the tests. - The generated
.fsialso listsval privatevalues. Delete them.
- A member that only tests use: write
-
Hide representations where callers must not build values. Replace the record or union body in the
.fsiwith the bare type name and keep the constructor function:/// An email address that contains one '@'. type Email module Email = val create: input: string -> Result<Email, string> val value: email: Email -> stringCallers then get FS0039 for
{ Value = ... }or a union case. Keep a record public when every combination of its field values is valid. For the choice of type and constructor, use parse-dont-validate. -
Fix the traps. Read references/TRAPS.md for the diagnostics. The most important rules:
- Copy each attribute on a type or value to the
.fsi:[<RequireQualifiedAccess>],[<Struct>],[<Literal>]. Do not add an attribute that the.fsdoes not have:[<Sealed>]on a class in the.fsionly gives FS0297. A missingRequireQualifiedAccessgives only warning FS3888. The build passes, and callers can use the cases without the type name. - List each implemented interface:
interface System.IDisposable. Without it, you get warning FS0050, andusein a caller fails with FS0193. - Put XML doc comments in the
.fsi. When a.fsiexists, a doc comment that is only in the.fsis not in the XML documentation file. - Write each
valwith the same generic arity andinlineflag as the.fs.
- Copy each attribute on a type or value to the
-
Check the result.
- Build every project and run the tests. Treat FS3888 and FS0050 warnings as errors to fix.
- Run
public-surface.ps1again and compare withbefore.txt. Each removed line must be a member that no caller uses. No line may be added. - Write a small probe file in a consumer that builds a hidden record or union directly. It must fail to compile. Then delete it.
Verify
Run the bundled proof from the skill root:
pwsh ./scripts/run.ps1
The runner builds scripts/Sample and checks its public surface, its XML
documentation, and an InternalsVisibleTo test project. It builds
scripts/Generate with --allsigs and checks the generated .fsi.
It then builds each folder in scripts/breaks and checks the diagnostic
codes in its // expect: lines.
These rules come from the F# documentation and are not bundled proofs: a val inline
in a .fsi makes the function body part of the contract, so callers must build again
when the body changes. Use inline in a .fsi only when the function needs it, for
example for statically resolved type parameters.