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

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

    https://github.com/ruby/rbs

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

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

    https://rubygems.org/gems/rbs2ts

    https://github.com/mugi-uno/rbs2ts

    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とかを見るとまだまだサポートできていない部分がたくさんあるので、ちまちま更新していきたい。