Part 1 — What bidirectional typing really is
The first chapter of the Seasoned volume. The Seasoned volume is the “read it” volume, not the “build it” one — we give proper names to the mechanism we built unconsciously in the Little volume, and read the back of it in formal language. This chapter writes almost no new code. It’s an overview (a map) revealing what the things we already wrote actually were — though at the end we peek, in just one place, at the point where checking
⇐emits a diagnostic.
In the Little volume (The Little chibirigor), we built two functions as the heart of the inferencer:
type_of(node, scope, diag)— find a type from an expression.accepts(expected, actual)— check whether a type fits an expectation.
These two have proper names in the world of type theory. That’s bidirectional typing. The Seasoned volume starts here.
1-1. Two directions — synthesis and checking
Section titled “1-1. Two directions — synthesis and checking”Typing has two modes of opposite direction.
- Synthesis (
⇒): look at an expression and build a type from the bottom up. The Little volume’stype_ofis this. See1, it’sInteger; seea + b, it’s (from both their types)Integer. Input is just the expression, output is the type. - Checking (
⇐): against an already-expected type, check from the top down whether the expression fits. The Little volume’saccepts(and the dispatch that calls it) is this. “AnIntegeris wanted here. Does this expression fit?” Input is the expression and the expected type, output is a verdict (three-valued in chibirigor).
The Little volume’s type_of was through-and-through ⇒, and only where dispatch looks at
arguments was it ⇐. That is, chibirigor was bidirectional from the start. As a figure, it’s
two arrows of opposite direction:
expected type T (RBS signature · annotation · declared return type) │ │ ⇐ check : S <: T ? ── mismatch → diagnostic ▼ expr e ──⇒──▶ type S synthesize: build a type from the expression (chibirigor never fails; unknown → untyped)▼ Figure 1-1 — synthesize
⇒(build a type from the bottom up) and check⇐(match against the expected type above)
1-2. Writing the rules formally
Section titled “1-2. Writing the rules formally”In the Seasoned volume we don’t fear notation. A typing rule writes the “premises” above a horizontal line and the “conclusion” below. There are two judgments:
- Synthesis:
Γ ⊢ e ⇒ T(under environment Γ, synthesizing the type of expression e gives T) - Checking:
Γ ⊢ e ⇐ T(under environment Γ, expression e checks against expected type T)
(⊢ is a type-theory symbol read “turnstile,” meaning under the premises on the left, the right
is derivable. ⇒/⇐ were the synthesis/checking directions.)
Γ (gamma) is the Little volume’s Scope (variable name → type). For example, the synthesis rule
for a variable reference:
x : T ∈ Γ ─────────── (Var-Synth) Γ ⊢ x ⇒ T“If x : T is in Γ, then x’s type is synthesized as T.” The Little volume’s
scope.local(node.name) || Dynamic is exactly this rule (with a chibirigor-style footnote: if it
isn’t there, fall back to untyped).
And the most important rule, tying the two directions together — subsumption:
Γ ⊢ e ⇒ S S <: T ───────────────────── (Sub) Γ ⊢ e ⇐ T“Synthesizing expression e gave S. If S is a subtype of T, then e checks against T.” Checking is
‘synthesize, then match by subtyping’ — this is the true form of the Little volume’s “type_of
an argument, then accepts.” The relation between <: (subtyping) and chibirigor’s three-valued
accepts is pinned down in Part 2.
1-3. Why a diagnostic only comes out in checking (⇐)
Section titled “1-3. Why a diagnostic only comes out in checking (⇐)”In the Little volume we wrote the most important sentence thus: “a diagnostic only appears where the expected type is fixed.” In bidirectional words:
Let’s be precise here. It is not that “annotation-free code has no ⇐ position” — a core-type
method call (the + in 1 + x, etc.) is a ⇐ position even with zero annotations, because RBS
gives the argument’s expected type. So why does working code still go unflagged? There are two
reasons, both corresponding to the Little volume’s Part 9 “deliberately miss”:
- Synthesis
⇒deliberately doesn’t fail (chibirigor-specific). In bidirectional typing in general, synthesis can fail on an unbound variable or a form with no synthesis rule. chibirigor intentionally synthesizes every unknown intountyped, totalizing it. So even when “an expression that lost its type” comes to a⇐position, the synthesis result isuntyped. untypedpasses straight through checking.acceptsis unconditionally:maybe(not punished) if either side isDynamic. So even whenuntypedcomes to a⇐position, it’s not a diagnostic.
To sum up — working code goes unflagged not because “there’s no ⇐ position” but because
“synthesis collapses the unknown to untyped, and checking doesn’t punish untyped.” You can
state this property only by laying chibirigor’s two gradual design judgments — ① totalizing
synthesis, ② lenient checking — onto the bidirectional framework. Seasoned Part 7’s “deliberately let go of
soundness” catches these same two points in the language of progress/preservation — we recover, late
in the volume, the foreshadowing dropped at the entrance.
1-4. The robustness principle is the direction itself
Section titled “1-4. The robustness principle is the direction itself”In Little Part 7 we saw the asymmetry “strict on returns, lenient on arguments.” Seen bidirectionally, this is the direction itself:
- The return value is synthesis
⇒: build the narrowest type from the body (strict = high precision). - An argument is checking
⇐: receive leniently against the expected type (let:maybethrough too).
The robustness principle Rigor upholds (Postel’s law: “strict in what you return, liberal in what you accept”) overlaps the two directions of bidirectional typing exactly, on one sheet. In the Little volume it appeared as a practice; in the Seasoned volume, as theory.
1-5. How it works inside Rigor
Section titled “1-5. How it works inside Rigor”Real Rigor’s architecture has these two directions as parts directly:
ExpressionTyper(typing an expression) = synthesis⇒. Pure, non-destructive, builds a type from an expression.acceptsat a call site (the acceptance check, three-valued + reason) = checking⇐. Works only where an RBS declaration gives the expected type T.Scope/FactStore= the environment Γ (far richer than the Little volume’s; treated in Part 6).
chibirigor’s type_of / accepts / Scope were the minimal version of these three. The Seasoned
volume adds flesh to this skeleton.
1-6. What the Seasoned volume stacks on this — the volume’s map
Section titled “1-6. What the Seasoned volume stacks on this — the volume’s map”With the map of bidirectionality in hand, the rest of the Seasoned chapters are surveyable by “which part of the map they fill in.” The whole is one long climb: “① bidirectional (the map) → ② the structure of types → ③ inference and flow → ④ the peak of theory → ⑤ the bridge”:
- Part 2 Subtyping and variance: refine subsumption’s
<:. In particular, the story where a function’s argument is contravariant — the direction twists one more turn (the climax of 『しくみ』ch. 7). - Part 3 Generics and type substitution: type variables
(X) -> Xand type substitution (System F). Part 5’s inference references this backward. - Part 4 Recursive types: μ and coinduction: how
⇒/⇐handle a type that references itself (μ-types / coinduction ↔ Rigor’s HKT). Adjacent to Part 3, tying up the mutual reference of α-equivalence. - Part 5 Real type inference: fill in what the Little volume fell back to
untypedwith⇒— arguments in particular — from how they’re used in the body. Widening⇒’s coverage (the frontier of『しくみ』ch. 9’s exercises). The TypeProf comparison is consolidated here too. - Part 6 The complete FactStore: extend the environment Γ into a flow-sensitive set of facts.
- Part 7 Soundness and normalization: the peak that pairs with this chapter’s bidirectional map. Read the design that “deliberately lets go” of soundness in the language of progress/preservation. We recover §1-3’s foreshadowing here.
- Part 8 Toward real Rigor: the bridge handing off from the minimal version to real Rigor.
1-7. This chapter’s summary
Section titled “1-7. This chapter’s summary”We wrote almost no new code. Instead we gave names to what we built in the Little volume:
| What we wrote in the Little volume | What it really is |
|---|---|
type_of (find a type) | synthesis ⇒ (synthesize) |
the argument check of accepts / dispatch | checking ⇐ (check) = synthesize, then <: |
Scope | the type environment Γ |
| ”diagnostics only where there’s an expectation” | a diagnostic is born only at a ⇐ position (follows from the structure) |
| “strict on returns, lenient on arguments” | synthesis ⇒ strict / checking ⇐ lenient (the robustness principle) |
1-7-a A note — the first point where ⇐ emits a diagnostic (check(rbs:) mode)
Section titled “1-7-a A note — the first point where ⇐ emits a diagnostic (check(rbs:) mode)”In §1-3 we said “a diagnostic only appears in checking (⇐).” The check up to the Little volume
used checking only in “dispatch’s argument check.” Add to it a check(source, rbs:) mode that
checks the body against a declared return type, and a scene arises where ⇐ works outside the
“caller side” too. The only code we peek at this chapter is this one — a small window for seeing
where checking ⇐ first raises a diagnostic.
rbs = <<~RBS class Greeter def greet: () -> Integer endRBS
Chibirigor.check('def greet; "hi"; end', rbs: rbs)# => [{line: 1, message: "return type Integer is declared but \"hi\" is returned"}]
Chibirigor.check('def greet; 1; end', rbs: rbs) # => [] (matches)Chibirigor.check('def greet; "hi"; end') # => [] (no declaration = no check)How it works (excerpt from lib/chibirigor/checker.rb)
- Collect body errors in the usual
eval_statementloop (as before). - If
rbs:is present, load the user declarations withRbs.load(rbs), and for eachDefNodecallcheck_against(node, declared_return, body_type, diagnostics). check_againstadds a diagnostic only whenAccepts.call(expected, actual) == :no. Ifuntypedis involved, it stays quiet (gradual’s promise).
# ⇐ subsumption: check actual against expected. diagnose only :no.def check_against(node, expected, actual, diagnostics) return if expected.is_a?(Type::Dynamic) || actual.is_a?(Type::Dynamic) return unless Accepts.call(expected, actual) == :no
diagnostics << diagnostic(node, "return type #{expected} is declared but #{actual} is returned")endThis is the minimal point at which ⇐ does work outside “arguments.” Recall §1-4’s robustness
principle — “the return type strictly (checked with ⇐)” appears in the implementation for the
first time here. It pairs with the Little volume’s dispatch “checking arguments with ⇐.”
Zero-FP guarantee. Pass no rbs: and it checks nothing (opt-in). If the declaration is
untyped, no check. Even if the body is untyped (when inference lost the type), no check. With
this, “never frighten working code” is kept in return-type checking too.
Exercises
Section titled “Exercises”- Using only the subsumption (Sub) rule, explain in two lines that “no diagnostic is born on the
synthesis
⇒side” (hint: where in Sub’s premise does the failure ofS <: Toccur). - Consider annotation-free Ruby
1 + x(xunbound). (a) What isx’s synthesis result? (b) Is+’s argument position a⇐position? (c) Why is there still no diagnostic — which of §1-3’s two reasons works? - Restate the robustness principle “strict on returns, lenient on arguments” using only the words
⇒/⇐(with a phrase for why each direction is strict/lenient).
Next chapter (Part 2): we build subsumption’s <: head-on. The width/depth subtyping of
objects, and contravariance — where the argument direction alone reverses when you pass a
function — that climax『しくみ』ch. 7 called “variance changing direction in the implementation.”
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.