Interface Files
A compiler can consume a dependency's exported signatures without reparsing its implementation. `.hi`, `.mli`, `.d.ts`, C++ BMIs and Go export data are all the same idea, and it is what makes incremental compilation work at scale.
How does a compiler type-check my code against a library it never parses?
An interface file is a serialized symbol table: the exported names of a unit with their types, their visibility, and whatever extra the language chose to publish — layouts, constant values, inlinable bodies. It is a compiled artifact in the same sense that an object file is, and it exists to answer one question for a dependent: what may I refer to, and what is its type? The implementation that produced it is not present and is not needed.
A dependent compiled against an interface is correct only if the implementation that eventually links against it still satisfies that interface exactly — same names, same types, same layouts for types passed by value, same calling conventions. Toolchains that generate the interface from the implementation enforce this by construction; toolchains where a human writes it (a C header, a .d.ts for a JavaScript library) enforce nothing, and every mismatch becomes a runtime fault instead of a compile error.
Key points
- An interface file is a serialized symbol table: exported names, their types, and whatever else the language decided dependents may see.
- It lets a dependent be type-checked and compiled without the provider's source ever being parsed.
- Hashing the interface separately from the object file is what allows a body-only change to stop propagating.
- Generated interfaces cannot drift from the implementation; hand-written ones — C headers,
.d.tsfor JavaScript libraries — can, and their mismatches surface at runtime. - Putting bodies in the interface buys cross-module inlining and gives back the insulation that made the interface valuable for builds.
The same mechanism under six names
Every language with a serious build story ends up with this artifact, because without it a dependent must parse the provider's source, and parse work is the thing that does not scale. The names differ and the contents differ; the shape does not.
What varies is who writes it and how much it contains, and those two variables predict everything about the system's build behavior. Generated interfaces cannot drift from their implementation; hand-written ones can and do. Interfaces that carry bodies enable cross-module inlining and invalidate dependents when a body changes; interfaces that carry only signatures do the reverse.
| Artifact | Language | Written by | Carries bodies? |
|---|---|---|---|
.hiimplementation | Haskell | Generated by GHC | Yes — unfoldings, which is how cross-module inlining works |
.mli | OCaml | Written by the programmer, checked against the .ml | No — .cmx carries the inlining information separately |
.d.ts | TypeScript | Generated from TS, hand-written for JS libraries | No — types only; the values live in JavaScript |
BMI / .pcm | C++20 | Generated by the compiler from a module interface unit | Yes for exported inline and template definitions |
| Export data | Go | Generated by the compiler into the archive | Yes for functions the inliner is allowed to consider |
.rmeta | Rust | Generated by rustc | Yes for generics and #[inline] items, which must be codegened by the consumer |
.h | C / C++ | Written by the programmer, checked against nothing | Whatever you put in it, and it is re-parsed every time |
Why this is what makes incrementality possible
Consider a chain: C is used by B, which is used by A. Change the body of a function in C without changing its signature. If B depends on C's *source*, B must be recompiled, and then A must be too. If B depends on C's *interface*, and the interface did not change, B is untouched and so is A. The rebuild stops at C.
That is the entire mechanism behind rebuilds that are proportional to the change. It is why build systems hash the interface artifact separately from the object file, and why [[build-dependency-graph]] distinguishes a body change from a signature change. Without a stable interface artifact to hash, every change looks like a signature change.
- Edit C bodyyou write itC's source differs; C's exported signatures do not.
- Recompile Cbuild timeA new object file, and a regenerated interface artifact.New machine code for C.
- Compare interfacebuild timeOld interface hash versus new interface hash.The decision that ends the cascade — if the hashes match, nothing downstream is affected.
- B and Abuild timeTheir existing object files, reused.Nothing. They were compiled against an interface that has not changed.
- Linkbuild timeA new executable with one new object file in it.The changed behavior.
Read it asRow three is the only interesting one. Everything else is bookkeeping. A build system that cannot compute that comparison — because there is no interface artifact, only source — must skip to the pessimistic answer and rebuild B and A as well, which is exactly what a C++ header change does.
The inlining leak
.hi file, and which items rustc puts in .rmeta, are heuristics tuned per release and controlled by pragmas like INLINE, NOINLINE and #[inline]. Two GHC versions can produce different interface contents for identical source, and interface files are not portable between compiler versions at all. The trade — bodies for inlining versus signatures for insulation — is universal; the specific cut point is not.There is a tension the table above hints at and every real system has to resolve. If the interface carries only signatures, dependents are beautifully insulated from implementation changes — and no call across the boundary can ever be inlined, because the body is not there. If the interface carries bodies, cross-module inlining works and the insulation is gone: changing a published body changes the interface, and dependents recompile.
GHC is the clearest case. A .hi file contains *unfoldings* — the actual Core for functions small enough or marked INLINE — so a change to such a function invalidates every module that imported it, while a change to a large unpublished function invalidates nothing. Rust has the same property for generic functions, which must be monomorphized in the consumer and are therefore necessarily part of the interface — see [[monomorphization]].
How it works
The steps, in the order the compiler takes them.
- After type-checking a unit, the compiler serializes the exported portion of its symbol table into an interface artifact.
- The artifact records names, types, visibility, constant values and — depending on the language — layouts and bodies eligible for inlining.
- A dependent's frontend deserializes the artifact directly into its own symbol table instead of lexing and parsing anything.
- The build system records a hash of the interface artifact as an input to every dependent.
- On rebuild, if the regenerated interface hashes equal, dependents are considered unaffected regardless of how much the implementation changed.
- The object file is a separate output with its own hash, consumed by the linker rather than by any compiler.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A hand-written
.d.tssays a function returnsstringand the JavaScript returnsstring | undefined; the type checker is satisfied and a downstream.lengththrows in production. - A stale interface artifact is used because a timestamp did not advance, and the dependent is compiled against signatures that no longer exist — the compile succeeds and the link fails with an unreadable mangled name.
- Interfaces are shared across compiler versions in a cache, and the build fails with a deserialization error that reads like a corrupted file rather than a version mismatch.
- A hot cross-module call stops being inlined because a function grew past the compiler's unfolding size threshold, and a benchmark regresses with no visible change to that function's behavior.
- A trivial refactor invalidates half the build because the changed function was small enough to be published in the interface, and nobody can see why from the diff.
When it helps
- Any build large enough that parse and type-check time dominate, which is where most compiled-language builds end up.
- Distributing a library without source: consumers get an interface and a binary and can compile against both.
- Type-checking against code in another language entirely, which is exactly the job
.d.tsdoes for TypeScript over JavaScript.
When it hurts
- When the interface is hand-written and unchecked, in which case it is a claim rather than a fact and the type system is proving something about a fiction.
- When maximum runtime performance is the goal and the interface hides the bodies the optimizer needed; LTO exists partly to undo this.
What it costs
Every one of these is paid by something.
- Reading a compiled interface buys the elimination of repeated parsing, and pays with an extra build artifact, an extra dependency edge, and a binary format that is usually invalid across compiler versions.
- Publishing bodies in the interface buys cross-module inlining and pays with invalidation: a body change is now an interface change, so dependents rebuild.
- Hand-written interfaces buy the ability to describe code the compiler cannot see, and pay with an unverifiable contract whose violations appear at run time.
- Generated interfaces buy correctness by construction and pay with a build ordering constraint — you cannot compile a dependent until the provider has been compiled at least far enough to emit its interface.
What else you could do
What a different compiler or language does instead, and when that is better.
- Re-parsing the provider's source every time, which is what
#includedoes. Simple, verifiable, and the reason C++ builds are what they are. - Precompiled headers serialize the frontend's state for a fixed prefix rather than for a semantic unit — the same performance idea with none of the encapsulation.
- A whole-program compiler skips interfaces entirely because nothing is separate; it pays the cost described in
[[whole-program-optimization]]. - Dynamic languages resolve names at run time and need no interface at all, trading every compile-time guarantee for the ability to not have a build.
See it for yourself
The flag, dump or tool that shows you this directly.
ghc --show-iface Foo.hiprints a Haskell interface, including which unfoldings were published — the inlining trade made visible.ocamlc -i foo.mlprints the interface the compiler infers, which is the starting point for a hand-written.mli.go tool nm, andgo build -gcflags=-mto see which functions were considered inlinable across a package boundary.clang++ -module-file-info widget.pcmdumps the contents of a C++20 binary module interface.tsc --declarationgenerates.d.tsfrom TypeScript source; diff two runs to see which source edits actually changed the public surface.
Plausible wrong readings
Stated the way a confident engineer states them.
- "An interface file is documentation." It is an input to the compiler. Its contents decide what type-checks and, in several languages, what gets inlined.
- "A
.d.tsis checked against the JavaScript." Nothing checks it. It is an assertion about code the type checker has never seen. - "If the interface did not change, nothing downstream can be affected." True for correctness, not for performance: an unpublished body change can still change what a dependent's already-compiled code does, because the call is real.
- "Interface files can be cached across machines like object files." Only if the compiler version, flags and target match exactly. They are far more version-fragile than object files.
Misconceptions
The claim, and what is actually true.
.mli is the notable exception, and it is a source file the programmer maintains deliberately.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When you use a library, your compiler needs to know the names and types it offers, not how any of it is written. An interface file is exactly that list, produced once by the compiler and read by everyone who depends on it. Because it is a small file that changes only when the public surface changes, the build can tell that an internal change affects nobody.
practical
Two practical rules follow. First, keep the public surface small: every exported name is an invalidation edge, and shrinking it makes builds more incremental as a side effect of good design. Second, be suspicious of hand-written interfaces. A .d.ts for an untyped JavaScript package is a guess, and when it is wrong the type checker will confidently prove something false. If the package publishes its own types generated from source, prefer those.
advanced
The design question is what belongs in the interface, and the honest answer is that it is a negotiation between the type system and the optimizer. The type system wants the smallest interface that determines well-formedness. The optimizer wants bodies, constants, layouts and effect information. The build system wants the interface to change as rarely as possible. GHC lets you move the line by hand with INLINE and NOINLINE; Rust forces generics onto the optimizer's side of it whether you want that or not; OCaml puts the line in a source file so it is reviewed. Notice that none of them found a way to avoid the choice — it is a real conflict between three consumers of the same artifact.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
.hi, a rustc .rmeta and a Clang .pcm are each invalid when read by a different version of the same compiler, and meaningless to a different vendor. Object files are far more portable than interfaces, which is the opposite of most people's intuition.-funfolding-creation-threshold; Go uses an inlining budget in the compiler; both change between releases, so identical source can produce different interface contents on a compiler upgrade..mli semantics in the language; the .cmi binary format is implementation-defined.If you were asked this in an interview
- How can a compiler type-check my code against a library whose source it never reads?
- Why does changing a small function in Haskell sometimes rebuild the world and changing a large one rebuild nothing?
- What is unverified about a hand-written
.d.ts, and what does that cost?
Connections
- DevOps / Production Engineering — Artifact caching and cache-key design across machinesInterface artifacts are cacheable in principle and dangerously version-fragile in practice, so the cache key must include compiler version, flags and target. Designing and operating that cache is a build-infrastructure problem owned there; we only own the question of what the artifact contains and when its contents change.