コンテンツにスキップ

Part 1 リテラルと算術

この章のゴールは、値に型をつける一番小さな仕組みを作り、checkannotateを実Rubyで動かすことです。

新しく出すのはConstという型ひとつと、type_ofという関数ひとつだけです。


型をむずかしく考える必要はありません。ここでは「値につけた小さなラベル」だと思ってください。1には「整数」、"hi"には「文字列」というラベルが付きます。

型チェックとは、このラベル同士がケンカしていないかを見ることです。

『型システムのしくみ』2章でも、型を{ tag: "Number" }のようなただのデータで表していました。私たちも同じく、型をRubyのオブジェクトで表します。

ここで頭を一つ切り替えてください。型はIntegerのような「クラス」そのものではなく、型を表すデータですConst[1]のように「クラスでは書けない細かい型」も作りたいので、専用のデータで表します。

この章で使うのは、次の3つだけです。

module Chibirigor
# 「この値そのもの」を表す型。例: Const[1], Const["hi"]
Const = Data.define(:value) do
def to_s = value.inspect
end
# 名前付きクラスを表す型。例: Nominal[:Integer](1-2 の「丸め」で使う)
Nominal = Data.define(:name) do
def to_s = name.to_s
end
# 「知らない・確かめようがない」を表す型(あとで大活躍する)
Dynamic = Data.define do
def to_s = "untyped"
end
end

ここで、三つの視点(この本の恒例の枠組み)で整理してみます。

  • ① 型理論:型は値につけるラベルで、内部ではただのデータです(『しくみ』2章)。
  • ② Rubyだと1のクラスはInteger"hi"のクラスはStringです。RubyもRBSも「1Integer」とまでしか言いません。
  • ③ Rigorだと:Rigorはもう一歩踏み込んで、1という値そのものを型にします(Const[1])。「Integer」ではなく「1」です。なぜそんな細かいことをするのか。これがあとでcaseの枝分けや定数の計算で効いてきます。いまは「Rigorは型を細かく覚える」とだけ覚えておけば十分です。

型がわからないときや確かめようがないときは、Dynamicuntyped)に逃がします。「知らない、確かめようがないなら黙っておく」という印です。


型チェッカーの心臓は、「式を受け取って型を返す」関数ひとつです。『しくみ』ではこれをtypecheckと呼んでいました。私たちはtype_ofと呼びます。

コードはPrismでパースします。Prism.parse("1").valueを辿ると、1IntegerNode"hi"StringNode、というふうに種類ごとのノードになっています。種類で場合分けするだけです。

module Chibirigor
module_function
def type_of(node, diagnostics)
case node
when Prism::IntegerNode then Const[node.value]
when Prism::FloatNode then Const[node.value]
when Prism::StringNode then Const[node.unescaped]
when Prism::TrueNode then Const[true]
when Prism::FalseNode then Const[false]
when Prism::CallNode then type_of_call(node, diagnostics)
else
Dynamic.new # 知らないノードは「脅かさない」── だまって untyped を返す
end
end
end

『しくみ』のtypecheckswitch (t.tag)だったのと、ほとんど同じ形です。違いは最後の行です。

『しくみ』は対象がきっちりしたミニ言語なので、知らない構文に出会うことはありません。

でも私たちが相手にするのは本物のRubyです。知らないものは必ず出てきます。そのときエラーにせずDynamic(untyped)を返します。

これがRigorの入口の姿勢です。

ここでRubyならではの事実が一つあります。1 + 2+も「メソッド送信」です。Prismでは1+というメッセージを送るCallNodeになります(1.+(2)と同じ)。

いまは算術だけを、ごく素朴に書きます。

module Chibirigor
module_function
def type_of_call(node, diagnostics)
recv = type_of(node.receiver, diagnostics)
args = node.arguments&.arguments || []
if node.name == :+ && integerish?(recv)
arg = type_of(args.first, diagnostics)
unless integerish?(arg)
diagnostics << diagnostic(node, "can't add #{arg} to an integer")
return Dynamic.new
end
# ★ ここがポイント:Const[3] とは計算せず、Integer に「丸める」
return Nominal[:Integer]
end
Dynamic.new # それ以外のメソッドはまだ知らない → 脅かさない
end
def integerish?(t)
(t.is_a?(Const) && t.value.is_a?(Integer)) || t == Nominal[:Integer]
end
# 診断は「どの行の・何が問題か」を持つ小さなハッシュ
def diagnostic(node, message)
{ line: node.location.start_line, message: message }
end
end

Nominal[:Integer]は1-1で定義した「整数クラス」を表すラベルです。

ここで、三つの視点の③(Rigorが困った所)が自然に顔を出します。

  • type_of(1)Const[1]type_of(2)Const[2]です
  • ではtype_of(1 + 2)Const[3]にすべきでしょうか
    • したい気もしますが、それには足し算を実際に計算しないといけません
    • x + 2ならもう値はわかりません
  • そこで結果はIntegerに丸めます
    • 「値そのもの」を覚えるのは便利ですが、どこかで手放して大ざっぱな型に戻す必要があります
    • いまは「足し算の結果はInteger」とだけ覚えれば十分です
    • この”いつ丸めるか”を実Rigorがどう体系立てるかは、後編で扱います

1-3. 矛盾を見つけるcheck(でも止まらない)

Section titled “1-3. 矛盾を見つけるcheck(でも止まらない)”

type_ofができたので、矛盾の報告であるcheckを作ります。やることは「トップレベルの文をひとつずつtype_ofにかけ、その途中で見つかった文句(diagnostics)を集める」だけです。

module Chibirigor
module_function
def check(source)
program = Prism.parse(source).value
diagnostics = []
program.statements.body.each { |stmt| type_of(stmt, diagnostics) }
diagnostics
end
end

checkの戻り値は{line:, message:}の配列です(どの行で何が問題か)。動かしてみます。

Chibirigor.check("1 + 2") # => [] (文句なし)
Chibirigor.check('1 + "x"') # => [{ line: 1, message: "can't add \"x\" to an integer" }]
Chibirigor.check("foo.bar") # => [] ← 知らないメソッドは黙って通す

『しくみ』のtypecheckは矛盾を見つけるとthrowしてそこで止まりました。私たちは違います。文句を配列に貯めて、最後まで読み進めます。

一つ目のエラーで止まらないし、わからない所(foo.bar)はそっと通します。これも「動くコードを脅かさない」の一部です。

  • ① 型理論:型チェックは、ラベルの矛盾検出です(『しくみ』2章)。
  • ② Rubyだと1 + "x"は実行すればTypeErrorになります。foo.barfoo次第で動くかもしれません。
  • ③ Rigorだと:確実に矛盾する所だけ報告し、わからない所は黙ります(止まらず、脅かさず)。

ここまで来ると、おまけがほぼタダで手に入ります。type_ofは型を作っているのだから、それを出力するだけで「推論した型を見せる」annotateになります。

check{line:, message:}の配列を返すのに合わせ、annotateは各文の{line:, type:}の配列を返します(行番号と、推論した型)。

module Chibirigor
module_function
def annotate(source)
program = Prism.parse(source).value
program.statements.body.map do |stmt|
type = type_of(stmt, []) # 文句は今は捨てる
{ line: stmt.location.start_line, type: type }
end
end
end

表示してみます。型はto_s1Integeruntypedのように出ます。

Chibirigor.annotate(<<~RUBY).each { |a| puts "#{a[:line]}: #{a[:type]}" }
42
"hello"
1 + 2
foo.bar
RUBY
1: 42
2: "hello"
3: Integer
4: untyped
  • 42Const[42]なので42と細かく出ます
  • 1 + 2は丸めてIntegerになります
  • foo.barはわからないのでuntypedです
  • untypedがどこに出るか」は「Rigorが型を見失った場所」であり、これが見えること自体がannotateの値打ちです(実Rigorのsig-genの発想の芽)

1-4b. 診断を読みやすくする(位置とキャレット)

Section titled “1-4b. 診断を読みやすくする(位置とキャレット)”

診断はだけでなく長さも持たせると、どこが問題かを指せます。Prismのノードは位置情報locationを持っているので、diagnosticをひとさじ拡張します。

def diagnostic(node, message)
location = node.location
{ line: location.start_line, column: location.start_column, length: location.length, message: message }
end

するとCLI(exe/chibirigor)は、該当行の下にキャレット^^^を引けます。

Terminal window
$ ruby exe/chibirigor check bad.rb
bad.rb:2:1: expected Integer but got "bad"
1 + "bad"
^^^^^^^^^

たったこれだけで「どの行の、どの語が」問題かが一目で分かります。実Rigorの診断はここをさらに作り込み、SARIFやGitHubの注釈に変換されます(ADR-51)。


作ったものは、型ConstDynamicNominal、関数type_ofcheckannotateです。全部で50行ほどです。『しくみ』2章のtypecheck(約40行)に、「丸め」「止まらない」「untypedに逃がす」が少し足された規模感です。

この章の三つの視点:

内容
① 型理論(『しくみ』2章 / 『TAPL』8章)型は値につけるラベル=ただのデータ。式から型を求める関数が心臓
② Ruby/RBS1Integer+すらメソッド送信。RBSは「1Integer」止まり
③ Rigor実装の問題値そのものを型にする(Const[1])と細かいが、いつIntegerに丸めるかが問題になる(続編で詳しく扱う)
  1. annotate("3.14")annotate("true")の結果を確かめ、Const#to_sがどう効いているか説明せよ。
  2. いまのtype_of_call+だけを丸めます。1 - 2Integerになるよう拡張せよ(ヒント:条件を:+/:-の両方に)。
  3. 1 + 2 + 3checkすると診断は出ません。なぜ矛盾なく通るのか、type_ofの再帰で説明せよ。

次章予告1 + 2で素通りした「+もメソッド送信」を、きちんと扱います。Rubyは何でもメソッド送信なので、「どのクラスのどのメソッドが何を返すか」を引く表(ディスパッチ)が要ります。そして「知らないメソッドはDynamicに逃がす」の続きと、§1-4で予告した定数畳み込み(畳めれば畳む)を発展ノートで扱います。


この章の実装(演習の答え合わせにも)impls/dist/part1/lib

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.