RBSからTypeScriptに変換するGem (rbs2ts) を作ってる

Ruby3.0 からは、型定義を処理するための rbs gem が同梱されていて、これは外部の *.rbs ファイルに記述した内容に従って、Rubyコードの型チェックを可能にしてくれる。

github.com

最近、この RBS の型定義を TypeScript の型定義に変換できないかな〜と思い、 rbs2ts という gem を実験的に作ってる。

結構荒削りなので、細々した部分での挙動は正直怪しいが、ある程度それっぽく動くようになったので公開してある。

rubygems.org

github.com

Gemのいまのところの挙動

いまのところ次のような変換ができる

Alias

RBS

type TypeofInteger = Integer
type TypeofFloat = Float
type TypeofNumeric = Numeric
type TypeofString = String
type TypeofBool = Bool
type TypeofVoid = void
type TypeofUntyped = untyped
type TypeofNil = nil

変換後TypeScript

export type TypeofInteger = number;

export type TypeofFloat = number;

export type TypeofNumeric = number;

export type TypeofString = string;

export type TypeofBool = boolean;

export type TypeofVoid = void;

export type TypeofUntyped = any;

export type TypeofNil = null;

リテラル

RBS

type IntegerLiteral = 123
type StringLiteral = 'abc'
type TrueLiteral = true
type FalseLiteral = false

変換後TypeScript

export type IntegerLiteral = 123;

export type StringLiteral = "abc";

export type TrueLiteral = true;

export type FalseLiteral = false;

Intersection, Union

RBS

type IntersectionType = String & Integer & Bool
type UnionType = String | Integer | Bool

変換後TypeScript

export type IntersectionType = string & number & boolean;

export type UnionType = string | number | boolean;

Optional

RBS

type OptionalType = String?

変換後TypeScript

export type OptionalType = string | null | undefined;

Array, Tuple

type ArrayType = Array[String]

type TupleType = [ ]

type TupleEmptyType = [String, Integer]

変換後TypeScript

export type ArrayType = string[];

export type TupleType = [];

export type TupleEmptyType = [string, number];

Record

RBS

type RecordType = {
  s: String,
  nest: {
    i: Integer,
    f: Float
  }?
}

変換後TypeScript

export type RecordType = {
  s: string;
  next: {
    i: number;
    f: number;
  } | null | undefined;
};

クラス

クラスっていうかメソッド。これが一番やばい。 これであってるのかホントに

RBS

class Klass
  attr_accessor a: String
  attr_reader b: Integer
  attr_writer c: Bool

  def required_positional: (String) -> void
  def required_positional_name: (String str) -> void
  def optional_positional: (?String) -> void
  def optional_positional_name: (?String? str) -> void
  def rest_positional: (*String) -> void
  def rest_positional_name: (*String str) -> void
  def rest_positional_with_trailing: (*String, Integer) -> void
  def rest_positional_name_with_trailing: (*String str, Integer trailing) -> void
  def required_keyword: (str: String) -> void
  def optional_keyword: (?str: String?) -> void
  def rest_keywords: (**String) -> void
  def rest_keywords_name: (**String rest) -> void
end

変換後TypeScript

export declare class Klass {
  a: string;
  readonly b: number;
  c: boolean;
  requiredPositional(arg1: string): void;
  requiredPositionalName(str: string): void;
  optionalPositional(arg1?: string): void;
  optionalPositionalName(str?: string | null | undefined): void;
  restPositional(...arg1: string[]): void;
  restPositionalName(...str: string[]): void;
  restPositionalWithTrailing(arg1: string[], arg2: number): void;
  restPositionalNameWithTrailing(str: string[], trailing: number): void;
  requiredKeyword(arg1: { str: string }): void;
  optionalKeyword(arg1: { str?: string | null | undefined }): void;
  restKeywords(arg1: { [key: string]: unknown; }): void;
  restKeywordsName(arg1: { [key: string]: unknown; }): void;
};

モジュール

RBS

module Module
  def func: (String, Integer) -> { str: String, int: Integer }
end

変換後TypeScript

export namespace Module {
  export declare function func(arg1: string, arg2: number): {
    str: string;
    int: number;
  };
};

インタフェース

RBS

interface _Interface
  def func: (String, Integer) -> { str: String, int: Integer }
end

変換後TypeScript

export interface Interface {
  func(arg1: string, arg2: number): {
    str: string;
    int: number;
  };
};

作ろうと思った動機

GraphQL Code Generator べんり!!

最近、ちゃんとGraphQLを使う機会があり、GraphQL Ruby によって出力されたスキーマファイルを元に GraphQL Code Generator で TypeScript 型定義に変更し、それをフロントエンドで利用するスタイルで開発していた。

この体験が非常に良くて、ある程度のバックエンド側の変更であれば、フロントエンド側への影響は TypeScript の型検査で拾うことができ、名前をタイポしてましたみたいな悲しい不具合はほぼ防げてた。

RESTつらい

GraphQLでの型の体験を味わうと、逆に次のようなものをなんとかしたくなってくる。

これらは、フロントエンド側でインタフェースを独自で型定義すれば、受け取った後についてはある程度検査できるが、あくまでも独自定義なので、当然バックエンドの変更に対して自動で追従することはできなくて、人力でなんとかする必要がある。

GraphQL以外の方法

全部GraphQLに置き換えちゃえばいいじゃん、というのがまず思い浮かぶストレートな解決策なんだけど、まあそれはほら、大変ですよね。

また、別のアプローチとして OpenAPI や gRPC を利用する方法もあるはず。gRPCなんかはそこまで詳しくないので的外れなことを言ってる可能性はあるが、すでに存在するAPI群に対して後追いで適用するには少しハードルが上がるものかな〜と思っている。 もちろんゼロから構築できるのであれば積極的に導入を検討してもよさそう。(楽しそうだし)

今回は、自分自身のリアルな課題への対処として、段階的にかつコスト低く徐々に保護される範囲を広げていく手段がほしいなぁと考えてた。

RBSからいけない?

というわけで、RBSが使えないかな?と思ったのが発端。

REST用のレスポンスはPresenterクラス的なもので構築してたりするので、バックエンドではそれを RBS 型定義でチェックした上で、その RBS から TypeScript の型定義が出力できれば、現実で稼働しているAPIを大幅に変更することなく、型定義の恩恵だけいい感じに受けられるのでは??という発想。

今後

まず自分が使わなければな..と思っている。 ドッグフーディングとは 意味/解説 - シマウマ用語集

一応出力されてる型定義は TypeScript Playground にペタッとしてエラーになってないことは確認してるけど、実際に使い物になるかは本気で使ってみないとわからんなという気持ちになってる。

ともあれ、実は何気にちゃんとRubyGems作るのも初めてなので、結構楽しい。 RBSのsyntaxとかを見るとまだまだサポートできていない部分がたくさんあるので、ちまちま更新していきたい。