😭

mobx-keystoneでプリミティブ型と相互変換される任意の型のカスタムpropを作ってみる

2021/03/04に公開

(2021/10/09) 更に新しい方法が用意されました

どちらかというと、この記事で最初解説していたようなプロパティの変換方法が、0.61.0になって公式に用意されました。今後は prop<OriginalType>.withTransform(...) を使いましょう。

(2021/04/02) この記事の内容は通用しなくなりました

この記事の内容ですが、2021/03/30 にリリースされた 0.55.0 により、 transform の機能 (および prop_ 系)がすべてドロップされ、通用しなくなりました。 😭

ちなみに、 functional model (MST っぽい書き方できるやつ)も全削除くらってます。怖っ。

今後はどうすればいいの?

今後は下記のようにシリアライズされた値を持つクラスを宣言するなどで表現すればいいと思います。

@model("DayJsModel")
export class DayJsModel extends Model(
  {
    // day.js の JSON 表現をシリアライズ表現として持つ
    value: prop<string>(),
  },
  {
    // 0.55.0 から追加された値型の宣言。
    // ツリーの複数箇所に挿入されてもクローンされるので問題ない。
    valueType: true,
  }
) {
  @computed
  get asDayjs() {
    return dayjs(this.value);
  }
}

上記の値オブジェクトを生成するユーティリティ関数も用意してあげましょう。

function dayJsModel(value: string | DayJs | Date): DayJsModel;
function dayJsModel(
  value: undefined | string | DayJs | Date
): undefined | DayJsModel;
function dayJsModel(value: unknown) {
  if (value === undefined) {
    return undefined;
  }
  if (isDayJs(value)) {
    return new DayJsModel({ value: value.toJSON() });
  }
  if (typeof value === "string" || value instanceof Date) {
    return new DayJsModel({ value: dayjs(value).toJSON() });
  }
  throw new Error("unknown value type for dayjs");
}

これらを使うモデルクラスのコードが下記です。

@model("SearchForm")
export class SearchForm extends Model({
  from: prop<DayJsModel | undefined>(),
  to: prop<DayJsModel | undefined>(),
}) {
  @modelAction
  setFrom(value: DayJs | undefined) {
    this.from = dayJsModel(value);
  }
  @modelAction
  setTo(value: DayJs | undefined) {
    this.to = dayJsModel(value);
  }
}

実際に廃止された prop_mapArray() の後継である objectMap() も同様の方針で作られています。(モデルクラス自身に Map インタフェースを実装してあり、もっと使いやすくなっていますが)

おそらく今後は prop を複雑にせず、モデルに機能をもたせる方向性にしたいのだと思いますが、正直ここらへんは MST のほうが上手くやっている印象があり、もう一波乱ぐらいあるような気がしています。(予想が当たらなければいいが…)


mobx-keystone で、MSTtypes.custom<string, 自分の好きな型> のようなプリミティブな型にシリアライズされるカスタム型を定義する方法が、今 ("mobx-keystone": "0.54.0") のところ公式で紹介されていないので、ここにメモしておきます。

モチベーション

そもそもこういうことをしたい場合、 day.js みたいなイミュータブルな日付を下記のように持ちたいというケースがだいたいだと思います。

@model("MyModel")
export class MyModel extends Model({
  timestamp: prop_dayjs("2021-03-03"), // もちろん prop<Dayjs>() すると怒られるぞ
}) {}

こういうケースについて、公式で今のところドキュメントされているのが、 Property Transforms ですが、一発 prop 宣言できる他の型に比べて微妙に面倒です。

const asDayjs = propTransform({
  // ... string <=> dayjs 間の変換ルール
});

@model("MyModel")
export class MyModel extends Model({
  timestamp: prop<string>("2021-03-03"),
}) {
  @asDayjs("timestamp")
  dayjsValue: Dayjs;
}

まあこれでいいといえばいいのですが…、やっぱりそのまま prop 定義できるほうが便利だよね。ということで、やり方を書いていきます。

propTransform による変換関数を定義する

カスタム prop を定義する場合でも必要です。

export const stringAsDayjs = propTransform<
  // シリアライズ時の型
  string | null | undefined,
  // デシリアライズ時の型
  Dayjs | null | undefined
>({
  // string へのシリアライズ (nullはバイパス)
  propToData(prop) {
    if (typeof prop !== "string") {
      return prop;
    }
    return dayjs(prop);
  },
  // Dayjs へのデシリアライズ (nullはバイパス)
  dataToProp(date) {
    return isDayjs(date) ? date.toJSON() : date;
  },
});

prop_dayjs() の実装をする

実際にカスタム prop の実装をしていきますが、基本的にはオリジナルの prop() に変換関数となる transform を追加で定義するのと、引数としてデフォルト値 (defaultValue)・デフォルト値関数 (defaultFn) が与えられた場合の変換後の値を入れるだけです。

// AnyModelProp など断り無く導入している型は mobx-keystone から import できます

export function prop_dayjs(def?: any): AnyModelProp {
  const p = {
    ...prop(def),
    transform: stringAsDayjs,
  };

  return Object.assign(p, {
    defaultValue:
      p.defaultValue === noDefaultValue
        ? noDefaultValue
        : stringAsDayjs.dataToProp(p.defaultValue),
    defaultFn:
      p.defaultFn === noDefaultValue
        ? noDefaultValue
        : () => stringAsDayjs.dataToProp((p.defaultFn as any)()),
  });
}

これで実装自体は完了です。

prop_dayjs() の型付けをする

上記だと any すぎるので、型を付けましょう。

type ValueOrValueFn<T> = T | (() => T);
type ExpectedPropValueType = string | Dayjs | undefined;

export function prop_dayjs(
  def?: ValueOrValueFn<ExpectedPropValueType>
): OptionalModelProp<string, Dayjs> {
  const p = {
    ...prop(def as any),
    transform: stringAsDayjs,
  };

  return Object.assign(p, {
    defaultValue:
      p.defaultValue === noDefaultValue
        ? noDefaultValue
        : stringAsDayjs.dataToProp(p.defaultValue as any),
    defaultFn:
      p.defaultFn === noDefaultValue
        ? noDefaultValue
        : () => stringAsDayjs.dataToProp((p.defaultFn as any)()),
  });
}

一応これで型はついた感じにはなるんですが、実際

  • p: prop_dayjs(dayjs()) => p: Dayjs
  • p: prop_dayjs() => p: Dayjs | undefined

なオーバーロードを表現できてないので、それを満たすようにしましょう。

type ValueOrValueFn<T> = T | (() => T);
type ExpectedDayjsValueType = string | Dayjs;

export function prop_dayjs(
  def: ValueOrValueFn<ExpectedDayjsValueType>
): OptionalModelProp<string, Dayjs>;
export function prop_dayjs(
  def?: ValueOrValueFn<ExpectedDayjsValueType | undefined>
): MaybeOptionalModelProp<string | undefined, Dayjs | undefined>;
export function prop_dayjs(def?: any): AnyModelProp {
  // ...
}

(なんかもっとマシな型の表現方法がありそう…)

まとめ

というわけで、 mobx-keystone でもドキュメント化されていないとはいえ、 MST のようなカスタム型のプロパティが作れます。

ただ、MST では CustomType | undefinedtypes.maybe(types_customType) みたいにプロパティを重ねて Maybe を表現できるのに比べて、mobx-keystone は prop_customType そのもので対応する必要があり、やや面倒ですね。

Discussion