Buildsimplementation

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.

The question

How does a compiler type-check my code against a library it never parses?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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.ts for 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.

Who writes it, and what it holdstypical
ArtifactLanguageWritten byCarries bodies?
.hiimplementationHaskellGenerated by GHCYes — unfoldings, which is how cross-module inlining works
.mliOCamlWritten by the programmer, checked against the .mlNo — .cmx carries the inlining information separately
.d.tsTypeScriptGenerated from TS, hand-written for JS librariesNo — types only; the values live in JavaScript
BMI / .pcmC++20Generated by the compiler from a module interface unitYes for exported inline and template definitions
Export dataGoGenerated by the compiler into the archiveYes for functions the inliner is allowed to consider
.rmetaRustGenerated by rustcYes for generics and #[inline] items, which must be codegened by the consumer
.hC / C++Written by the programmer, checked against nothingWhatever 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.

A body-only change in C, under two modelstypical
  1. Edit C bodyyou write it
    C's source differs; C's exported signatures do not.
  2. Recompile Cbuild time
    A new object file, and a regenerated interface artifact.
    New machine code for C.
  3. Compare interfacebuild time
    Old interface hash versus new interface hash.
    The decision that ends the cascade — if the hashes match, nothing downstream is affected.
  4. B and Abuild time
    Their existing object files, reused.
    Nothing. They were compiled against an interface that has not changed.
  5. Linkbuild time
    A 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

implementationWhich items GHC puts in a .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.ts says a function returns string and the JavaScript returns string | undefined; the type checker is satisfied and a downstream .length throws 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.ts does 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 #include does. 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.hi prints a Haskell interface, including which unfoldings were published — the inlining trade made visible.
  • ocamlc -i foo.ml prints the interface the compiler infers, which is the starting point for a hand-written .mli.
  • go tool nm, and go build -gcflags=-m to see which functions were considered inlinable across a package boundary.
  • clang++ -module-file-info widget.pcm dumps the contents of a C++20 binary module interface.
  • tsc --declaration generates .d.ts from 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.ts is 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.

The interface file is generated from the header.
In generated-interface languages it is produced from the implementation by the compiler. The header is the alternative to an interface file, not its source.
Interface files are for humans to read.
Most are binary and version-specific. .mli is the notable exception, and it is a source file the programmer maintains deliberately.
Adding a private helper cannot invalidate dependents.
If the helper is small enough for the compiler to publish as an unfolding, or generic and therefore monomorphized in the consumer, it is part of the interface whether you called it private or not.

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.

implementationInterface formats are compiler-private and version-locked. A GHC .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.
typicalCompilers that publish bodies in interfaces do so by size heuristic plus explicit pragmas. GHC uses an unfolding size threshold adjustable with -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.
specThe C++ standard requires that importing a module makes its exported declarations available without macro leakage, but says nothing about the interface artifact — not its format, not its file extension, not how a build system locates it. OCaml specifies .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

Domains that do not exist yet
  • DevOps / Production Engineering — Artifact caching and cache-key design across machines
    Interface 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.