[Sorbet] 型のあるRailsでの開発

Sorbetの基本事項

  • 漸進的型付け
  • デフォルトでランタイムでも検査される(T.castなどいくつかのメソッドはランタイムのみで検査され、静的検査はされない。)
  • 型定義のみのファイル(RBIというtsのd.tsのようなもの)があり、直接型定義が書けないものなどはRBIファイルを使用する

導入

Gemfile

1
2
3
gem 'sorbet', :group => :development
gem 'sorbet-runtime'
gem 'tapioca', require: false, :group => :development

command

1
2
bundle install
bundle exec tapioca init
  • 実利用時にはsorbet-railsを入れてtapioca dslを使わないという選択をしたりすることなるかと思います(ref)。
  • https://github.com/sorbet/sorbet/issues/4119 により、aarch64(ARM)のlinux上だと(sorbet-runtimeは動きますが)sorbetが動かないと思います。

静的チェックの有効化ついて

ファイル

ファイルの先頭にtyped: xxxという形式で型の厳格度を指定する

緩い厳しい
typed: ignoretyped: falsetyped: truetyped: stricttyped: strong
  • typed: ignore: 型チェックなし。
  • typed: false: シンタックス、定数が存在するかどうか、sigが正しいかどうかのみチェック。
  • typed: true: 型チェックあり。
  • typed: strict: すべてのメソッドにsigが必須。インスタンス変数は型の明示が必要。TypeScriptのnoImplicitAnyに近い。
  • typed: strong: T.untypedが許可されない。ほぼ使われないのであまり気にしなくて良い。

メソッド

sigが記述されているメソッドは型検査される

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# typed: true

class Hoge
  extend T::Sig

  sig { params(a: Integer).void }
  def hoge(a)
    puts a
  end

end

引数

T.untypedが指定されているものは型検査されない

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# typed: true

class Hoge
  extend T::Sig

  sig { params(a: T.untyped).void }
  def hoge(a)
    puts a
  end

end

呼び出し側

T.unsafeで指定されているものは型検査されない

1
2
3
4
5
6
7
8
# typed: true

class Hoge
  define_method(:hoge) { puts 'hoge' }
end

hoge = Hoge.new
T.unsafe(hoge).hoge

型システム

基本的なメソッドの型定義方法について

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# typed: true

class Hoge
  extend T::Sig # 各メソッドにsigを付与するために必要

  sig { params(a: String).returns(String) } # 通常の引数、戻り値は使用(Rubyの作り上戻り値自体は必ずあるので、使用するかどうかという考え方になる)
  def hoge(a)
    a
  end

  sig { params(a: String).returns(String) } # キーワード引数、戻り値は使用
  def fuga(a:)
    a
  end

  sig { void } # 引数なし、戻り値は未使用
  def piyo
    puts 'hoge'
  end
end

基本的な型一覧

補足
String“hoge”
Integer42
Float3.14
NilClassnil
T::Array[T]ジェネリクスなのでTは型パラメータ
T::Hash[T, U]ジェネリクスなのでT,Uは型パラメータ
T::Set[T]ジェネリクスなのでTは型パラメータ
T.nilable(A)Nilable Types. Aは任意の型
T.any(A, B, C)Union Types. A, B, Cは任意の型
T.all(A, B, C)Intersection Types. A, B, Cは任意の型
T.type_alias { A }型エイリアス(Type Aliases). Aは任意の型

型アサーション(Type Assertions)

型の上書き。 比較的よく使用するもののみ記載

T.let

T.letとT.castは、TypeScriptのas相当。 下で記述しているT.castとの違いは静的な検査を行うこと。

1
x = T.let(10, Integer)

T.cast

T.letとの違いは静的な検査が行われないこと。

1
x = T.cast(10, Integer)

T.must

non null assertion. TypeScriptの!相当。

1
2
3
x = T.let("hoge", T.nilable(String))
T.must(x)
T.reveal_type(x) # => String

T.bind

self用のT.cast的なもの。 T.castと異なり代入は不要。

1
2
3
4
5
6
7
class MyClass
  def method_on_my_class
  end
end

T.bind(self, MyClass) # これ以降selfがMyClass型として扱われる
self.method_on_my_class

T::Struct

HashだとT::Hash[KeyType, ValueType]としか型が書けないので、T::Structを基本使用することになる。

1
2
3
4
5
6
7
8
9
# typed: true

class Hoge < T::Struct
  prop :hoge, Integer # propは再代入可能
  const :fuga, Integer # constは再代入不可能。そもそもsetterがない
end

hoge = Hoge.new(hoge: 1, fuga: 1)
hoge.serialize # serializeメソッドを呼ぶとHashが返る

* 現状T::Structを継承したものを継承できない。つまり、上記のHogeを継承したクラスは作成できない。継承したい場合にはクラスで書くしかない。

T::Enum

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# typed: true

class A < T::Enum
  enums do
    Hoge = new('hoge') # newの引数には文字列が推奨されている。(you can pass any value to new (including things like Symbols or Integers). A future change to Sorbet may restrict this; we strongly recommend that you pass String values as the explicit serialization values.)と書かれている
    Fuga = new('fuga')
    Piyo = new('piyo')
  end
end

Hoge::Hoge.serialize # => 'hoge' newに渡したものがserializeの戻り値となる。newの引数は省略することができるが、省略した場合には小文字の文字列が返ってくるのみ(スネークケースになったりはしないので基本明示的に指定するほうが無難)。
Hoge::Fuga.serialize # => 'fuga'

Blocks, Procs & Lambdas

T.procで記述する。

1
T.proc.params(arg0: Arg0Type).returns(ReturnType)

罠系

selfを見失う。

1
2
3
4
5
6
class Post
  before_create :set_pending, if: -> {
    T.bind(self, Post) # T.bindでselfの型を指定しないと下のdraft?は型エラーになる(Sorbetの仕様上)
    draft?
  }
end

Proc.newはProc型になってしまうので使用を避ける必要がある。procやlambda(->)が推奨。

1
2
3
4
sig {returns(Proc)}
def proc_dot_new
  Proc.new {|n| n * 2 }
end

Flow Sensitive Typing

よくある(?)条件分岐の型推論もある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# typed: true

extend T::Sig

x = T.let("hoge", T.nilable(String))
if x
  T.reveal_type(x) # Revealed type: String
else
  T.reveal_type(x) # Revealed type: NilClass
end

T.absurd

T.absurdが書いてあると、下記のように処理の記述漏れがある場合には型エラーになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# typed: true

class Hoge < T::Enum
  extend T::Sig

  enums do
    A = new
    B = new
    C = new
  end

  def to_s
    case self
    when A then 'a'
    when B then 'b'
    else
      T.absurd(self) # Cが処理されてないのでエラーとなる
    end
  end
end

T.class_of

クラスの型を記述するときに使用する。

1
2
3
T.let(0, Integer) # ある型のインスタンスの型

T.let(Integer, T.class_of(Integer)) # T.class_ofがあればある型のクラスの型

T.self_type & T.attached_class

T.self_type

インスタンスメソッド利用時に自クラスを示す際に利用する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# typed: true

class Parent
  extend T::Sig

  sig {returns(T.self_type)}
  def foo
    self
  end
end

class Child < Parent; end

T.reveal_type(Parent.new.foo) # => Parent
T.reveal_type(Child.new.foo) # => Child

T.attached_class

クラスメソッド利用時に自クラスを示す際に利用する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# typed: true

class Parent
  extend T::Sig

  sig {returns(T.attached_class)}
  def self.make
    new
  end
end

class Child < Parent; end

T.reveal_type(Parent.make) # => Parent
T.reveal_type(Child.make)  # => Child

Generics

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# typed: true

class Box
  extend T::Sig
  extend T::Generic # type_memberメソッドを提供する

  Elem = type_member # Elemが型パラメータ

  sig {params(val: Elem).void}
  def initialize(val:)
   @val = val
  end
end

Box[Integer].new(val: 0)

Abstract Classes & Interfaces

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# typed: true

module Runnable
  extend T::Sig
  extend T::Helpers # 各種抽象クラスやインターフェースのメソッドを利用するために必要
  interface! # 抽象クラスにする場合にはabstract!にする。abstract!を使用した場合には実装を持てるが、interface!の場合には実装は持てない

  sig {abstract.params(args: T::Array[String]).void} # インターフェース側はabstract.paramsを使用する
  def main(args); end
end

class HelloWorld
  extend T::Sig
  include Runnable

  sig {override.params(args: T::Array[String]).void} # 実装側ではoverride.paramsを使用する
  def main(args)
    puts 'Hello, world!'
  end
end

その他ここに全く書いてないもの

運用で叩くであろうコマンド群

コミュニティが管理している型定義やtapioca等が生成している型定義を常に更新しないといけないので下記コマンド群を常に叩くことになる。

1
2
3
4
5
bin/tapioca annotations # コミュニティで管理されている型定義(RBI)の取得
bin/tapioca gem # gemのRBIの生成
bin/tapioca dsl # ActiveRecord等のRBIの生成. sorbet-railsを使用する場合にはここが bin/rails rails_rbi:all になる
bin/tapioca todo # TODOのRBIの生成
bundle exec srb rbi suggest-typed # RBIのコンフリクト解消

RBIについて

tapiocaで取得したり生成したRBIファイルは不完全なので、自分で定義を追加したり上書きしたりする必要がある。
追加する場合にはsorbet/rbi/shims以下にRBIファイルを置く。 詳細はこちら

その他Tipsとかの知見

参考

Sorbet Docs

その他

なにか間違いを見つけた場合等はTwitter等でご連絡ください。

Built with Hugo
テーマ StackJimmy によって設計されています。