reasonml

JavaScript のデータ (Js.t) から不要なプロパティを取り除く

Note:

-

Reason (OCaml) は JavaScript のオブジェクト (データ型全般ではなく辞書型のデータの方) をレコードっぽいデータ型として定義できます。例えば Reason ならこんな感じです。

/* "." をつけると JavaScript のオブジェクトとして扱われる */
type tesla = {
  .
  color: string
};

この定義は { color: "string" } として出力されます。 JavaScript と違う点は、 JavaScript 以外のデータ型 (function など) も出力できることです。

ですが、自動的に生成されるオブジェクトでは JavaScript 側で対応できない場合があります。それは「プロパティの有無によって挙動が変わる処理」がバインディング対象の JavaScript のコードに含まれている場合です。

引数にオプション (挙動を制御するためのデータで、 Reason の option 型ではありません) を取る関数が一つの例です。というかそれくらいだと思います。

私は今、 Sequelize のバインディングを書いてまして (もし配布を考えると貧弱な標準ライブラリで済ませるべきなのかというね...) 、そこでこの問題にぶつかりました。該当の関数はモデルを定義する Sequelize.define() です。この関数は引数にモデルの仕様をオブジェクトで取ります。ドキュメントから引用した JavaScript の例はこんな感じです。

sequelize.define('modelName', {
    columnA: {
        type: Sequelize.BOOLEAN,
        validate: {
          is: ["[a-z]",'i'],        // will only allow letters
          max: 23,                  // only allow values <= 23
          isIn: {
            args: [['en', 'zh']],
            msg: "Must be English or Chinese"
          }
        },
        field: 'column_a'
        // Other attributes here
    },
    columnB: Sequelize.STRING,
    columnC: 'MY VERY OWN COLUMN TYPE'
})

オブジェクトのプロパティである columnA, columnB, columnC がモデルの属性です。最も簡単な定義はプロパティ値にデータ型を指定するのみで (columnB) 、詳しく定義するならオブジェクトをセットします (columnA) 。

Reason では属性の定義を次のように記述しました (ほとんど上の例に出ませんが) 。

/* 一部の設定です */
type js = {
  .
  "type": Js.Json.t, /* 実体は関数ですが無視しといてください */
  "allowNull": Js.null(Js.boolean),
  "defaultValue": Js.null(Js.Json.t),
  "unique": Js.null(Js.boolean),
  "primaryKey": Js.null(Js.boolean),
  "field": Js.null(string),
  "autoIncrement": Js.null(Js.boolean),
  "comment": Js.null(string)
};

ちょっと説明が前後しますが、 {. で始まる定義の何が通常のレコードと違うのかと言うと、こちらはデータを生成したときの型が Js.t(..) になります。例えば先の tesla 型であれば、生成するデータは Js.t(tesla) 型です。

/* 単なる tesla 型ではない */
let value: Js.t(tesla) = {"color": "abc"};

Js.t 型の値は JavaScript のデータとして扱われます。どこで使うのかと言うと、 JavaScript の変数や関数のバインディングです。 Reason と JavaScript のデータ型は一部を除き互換性がないので、引数に渡すまたは戻り値を受け取る際に自前で変換する必要があります。意外なところだと bool も互換性がありません。

null がセットされる可能性のあるプロパティの型は Js.null('a) と定義します。 Reason の option 型は JavaScript の「 null or 値」ではありません。指定はできますが、 Reason の内部表現が出力されるので JavaScript 側にとっては不明なデータになります。ですので、少々面倒ですが option 型のデータは Js.Null.from_opt()null に変換してから渡す必要があります。

話は戻って、 Sequelize のバインディングです。 Sequelize.define() を呼ぶと JavaScript レベルでエラーが出ました。バインディングの型定義が間違ってないかとかオプションのフォーマットが間違ってないかとかあちこち調べた結果、 null をセットしていた field プロパティを外したら動きました。しかし field プロパティのデフォルト値は null なので問題ないはずです。でも null を指定するとエラーになる。どうも field プロパティは存在すれば (null 以外の) 値をセットしているとみなされてエラーになってしまっていたようです。

どうしてそんな処理になってるのかと言うと、 JavaScript 側で in 演算子や hasOwnProperty() を使ってプロパティの存在を判別しているからです。静的型付け依存症にとってはまったくもって傍迷惑ですが、どげんかせんと「ほら見ろ TypeScript の数倍面倒になるだけじゃねーかカタカタうるせー関数型オタクが」と言われて口が悪いだけの無能エンジニアとしてクビになってしまいます。ドキュメントを眺めても「任意のプロパティを出力しない」方法はなさそうでしたので、何か方法を考える必要があります。

まず考えられるのが JSON を使ってオブジェクトを構築する方法です。 これならプロパティを取捨選択するのも簡単です。ただし JSON 以外のデータ型を表せないことと、何のデータが入ろうとも Js.Json.t の型としか表せないため型付けのメリットに乏しいという問題があります。特に後者はわざわざ Reason を使うメリットを捨てるようなものです。

では Js.t のオブジェクト型を自力で組み立てたらどうでしょうか。悪くなさそうですが、プロパティごとに型が異なるので型の取り扱いが大変です。オブジェクトは Js.Dict.t('a) で表せますが、 'a がプロパティによって異なります。じゃあ 'a をバリアントにしてプロパティごとの型を指定して...としても型的なメリットは半分もないです。第一 JSON 以上に面倒です。

そこで逆に考えることにしました。「必要なプロパティを追加」していくのではなく、生成されたデータ Js.t('a) から「不要なプロパティを削除」すればいいのでは。初めからやれ? ごもっともですがどうやります? 例えば Js.t({. foo:string, bar:Js.null(string)}) というデータがあるとして、 bar = null だからカットしましたとなると Js.t({. foo:string}) という型になりますが、もう別の型です。

というわけで、まずサンプルのデータ型を考えます。 nameage の 2 つのプロパティを持つオブジェクトです。 "type t" についてはこちらで書きました。

module Person = {
  type t = {
    name: string,
    age: option(int)
  };

  type js = {
    .
    "name": string,
    "age": Js.Null.t(int)
  };

  /* JavaScript オブジェクトに変換する関数 */
  let toJS = (p: t) : js => {"name": p.name, "age": Js.Null.from_opt(p.age)};
};

/* サンプルデータ */
let alice = Person.{name: "alice", age: None};

let bob = Person.{name: "bob", age: Some(17)};

サンプルデータと JavaScript 変換後のオブジェクトの内容を表示してみます。

Js.log(alice);
Js.log(bob);
Js.log(Person.toJS(alice));
Js.log(Person.toJS(bob));

/* 出力結果 */
["alice",0]
["bob",[17]]
{"name":"alice","age":null}
{"name":"bob","age":17}

JavaScript ライブラリに渡したいデータは toJS() の戻り値です。ただし、 age プロパティは値が null 以外の場合のみ含めたいとします。アリスは年齢非公表なので、 {"name":"alice"} と出力したい。できればどのオブジェクトに対しても null のプロパティのみをカットする関数を用意したい。

処理だけを考えればたいして難しくありません。 JavaScript データの型を判別し、オブジェクトであれば各プロパティを調べればできます。動的型付けが主戦場の方にしてみれば何を悩んでるんだこいつと思われそうですが、まあ人生いろいろあるんですよ。未来の型エラーを防ぐために先取りして悩んでいるわけです。こう言うと何だかかっこいい。

関数名は diet() とします。出力のみを変更して型は変えたくないので、 diet() の型は Js.t(`a) => Js.t(`a) とします。 JavaScript のデータなら何でも引数にできます。データがオブジェクトでなければ何もせずに返します。以下が実装です。

type jsType =
  | Null
  | Undefined
  | Boolean
  | Number
  | String
  | Array
  | Object
  | Function;

/* JavaScript データの型を判別する */
let type_ = (js: Js.t('a)) : jsType =>
  switch (Js.typeof(js)) {
  | "undefined" => Undefined
  | "boolean" => Boolean
  | "number" => Number
  | "string" => String
  | "function" => Function
  | "object" =>
    if (Js.unsafe_le(Obj.magic(js), 0)) {
      Null;
    } else if (Js.Array.isArray(js)) {
      Array;
    } else {
      Object;
    }
  | _ => failwith("unknown type")
  };

let diet = (js: Js.t('a)) : Js.t('a) => {
  let rec diet0 = (js: Js.t('a)) =>
    switch (type_(js)) {
    | Object =>
      let dict: Js.Dict.t(Js.t('a)) = Obj.magic(js);
      let newDict = Js.Dict.empty();
      Array.iter(
        key => {
          let value = Js.Dict.unsafeGet(dict, key);
          switch (type_(value)) {
          | Null => ()
          | _ => Js.Dict.set(newDict, key, diet0(Obj.magic(value)))
          };
        },
        Js.Dict.keys(dict)
      );
      Obj.magic(newDict);
    | _ => js
    };
  diet0(js);
};

たいして複雑な処理でもないので詳細はコードを読んでもらうとして、怪しい操作は以下です。

  • オブジェクトは Js.Dict.t として扱えます。ただし任意の Js.tJs.Dict.t に変換する関数は用意されていないので、オブジェクトと判定できれば Obj.magic() で強引にキャストします。

  • プロパティの値は Js.Dict.unsafeGet() で取得できます。 unsafeGet() は指定したキーがなければエラーになりますが、ここではキーが存在するとわかっているので問題ありません。

  • プロパティの追加は Js.Dict.set() で行います。 Js.Dict.t の値の型は同じでなければならないので (Js.Dict.t('a) なら値は 'a のみ) 、セットする値も Obj.magic() でキャストして一つの型として扱わせます。

  • 不要なプロパティを除いたオブジェクトを生成できたら、ここでも Obj.magic()Js.Dict.tJs.t にキャストして返します。

これで null 値が含まれるプロパティを出力からカットできるようになりました。もっと汎用的な関数にできると思いますが、今はそこまで必要ないのでここまで。

Js.log(diet(aliceJS));
Js.log(diet(bobJS));

/* 出力結果 */
{"name":"alice"}
{"name":"bob","age":17}

以上のコードはこちらに置いておきます。 Obj.magic() を多用してますから型的にはまったく安全な関数ではないので注意してください。

長々と書いてきましたが、もっと簡単な方法があるのかもしれません。それらしきアノテーションないのかな。こういう記事書くと「やっぱり Reason (OCaml) は面倒だわやめよ」と思われそうだし...