Semantic Diff

#9198 Read @throws from library declarations in exception analysis

1 commit

ac7357822edb14ab7f695dcacff864e38205512e..pr-9198

1d38461ed · cknitt + Claude · Nov 03, 2026, 18:23 UTC

Read @throws from library declarations in exception analysis

Why

The exception analysis knew what library functions throw only from a table kept by hand in exn_lib.ml. The table drifted: the Temporal fromStringOrThrow functions were never added (#9182), and neither were a few Intl and Belt ones. Third-party libraries and a project's own externals had no way to say what they throw.

A call now takes the exceptions named by the @throws on the declaration of the value it calls, and the table is gone. The .cmi keeps the attributes of externals and interface declarations, so their @throws reaches calls in other modules and packages, through aliases, open, include, functors and first-class modules. For a let in a module without an interface, the type checker now copies the binding's @throws into the inferred signature, as it already did for @deprecated. The stdlib and Belt declare what their throwing functions throw, and a guard in the reanalyze tests fails when a stdlib or Belt *OrThrow or *Exn declaration has no @throws(...).

modifiedAnalysis exceptions of a call to f, Exception.Event loop

rule
Before
throws(f) = values(f)when f is in the values table
throws(f) = table(f)otherwise
After
throws(f) = declared(f) ∪ values(f)

modifiedSignature inferred signature of a module without an interface, Typemod.type_structure

/** Finds the value. */@throws(Not_found)let find = x => x > 0 ? x : throw(Not_found)
rescript
Before the signature in the .cmi
After
@throws(Not_found)let find: int => intlet find: int => int

modifiedReanalyze calls to annotated externals

// Lib.res@throws(JsExn)external parse: string => int = "parseInt" // App.reslet parse = s => Lib.parse(s) @throws(JsExn)let parseAnnotated = s => Lib.parse(s) let date = () => Temporal.PlainDate.fromStringOrThrow("x")
reanalyze Exception Analysis
Before
After
App.res:4:5-18
parseAnnotated throws nothing and is annotated with redundant @throws(JsExn)
App.res:1:5-9
parse might throw JsExn (App.res:1:17) and is not annotated with @throws(JsExn)
App.res:6:5-8
date might throw JsExn (App.res:6:17) and is not annotated with @throws(JsExn)
Analysis reported 1 issues (Exception Analysis:1)
Analysis reported 2 issues (Exception Analysis:2)

Fixes #9182

Review

  • Why the union of the declaration and the values table, rather than one winning? The values table holds the @throws on the implementation's binding, the declaration the one in its interface. Either can be the only one written, as with an annotated .resi let whose .res isn't annotated, so the call can throw what either names.
  • Why does the inferred signature take only the @throws family and @deprecated, and not every attribute? A doc comment is an attribute too: in the .cmi it would make editing a comment recompile the module's dependents. A module with a .resi is unchanged, since its .cmi holds the interface's declarations.
  • What does a @throws without a payload mean? Nothing, as on a binding. The guard requires a payload, and the stdlib's two payload-less @throws in Stdlib_JSON.res now name JsExn.