Object.groupBy で作られるオブジェクトの prototype は null

おさらい: prototype

JavaScript のオブジェクトはみんな prototype というのを持っていて, この prototype からプロパティを継承, より正確には, プロパティアクセス時にそのプロパティがオブジェクトに存在しなければ prototype を辿って見つけにいくことになっている.

あるオブジェクトを prototype とした別のオブジェクトを作るには Object.create を使う (あるいは new 演算子__proto__ を使っても良い).

const x = {};
x.foo = "foo";

const y = Object.create(x);
y.bar = "bar";

const z = Object.create(y);
z.baz = "baz";

console.log(z.foo); // => "foo"
console.log(z.bar); // => "bar"
console.log(z.baz); // => "baz"

逆に, あるオブジェクトの prototype を取得するには Object.getPrototypeOf を使う.

console.log(Object.getPrototypeOf(z) === y); // => true

ところで {} のような「空」のオブジェクトにも prototype が存在して, これは Object.prototype と呼ばれる. この Object.prototype には toString のようなプロパティが定義されているので, 空のオブジェクトは真に空というわけではない.

const x = {};
console.log(Object.getPrototypeOf(x) === Object.prototype); // => true
console.log(x.toString); // => [Function: toString]

では真に空のオブジェクトは作れないのかというとそんなことはなくて, prototype が null のオブジェクトを作れば良い. 例えば Object.create(null) などとする (ブログタイトル回収).

const w = Object.create(null);
console.log(Object.getPrototypeOf(w)); // => null
console.log(w.toString); // => undefined

Object.groupBy

Object.groupBy は ES2024 で追加されたメソッドで, 配列などの iterable をグループ化して, それらのグループをプロパティに持ったオブジェクトを作成できる.

const arr = [3, 1, 4, 1, 5, 9, 2]
const obj = Object.groupBy(arr, (e) => e % 2 === 0 ? "even" : "odd");
console.log(obj); // => { odd: [3, 1, 1, 5, 9], even: [4, 2] }

そして記事のタイトルの通り, Object.groupBy で作られるオブジェクトの prototype は null になっている.

console.log(Object.getPrototypeOf(obj)); // => null

このメソッドを追加する proposal の README を読むと, これには prototype から継承したプロパティと Object.groupBy によって作られたプロパティが混ざって困ったことが起こらないように, という意図があるようだ.

returns a null-prototype object, which allows ergonomic destructuring and prevents accidental collisions with global Object properties

https://github.com/tc39/proposal-array-grouping?tab=readme-ov-file#motivation

TypeScript と null-prototype オブジェクト

ところで TypeScript には prototype が null のオブジェクトを表す型はない. 空のオブジェクト型 {} がそれに該当すると思われるかもしれないが, 実は全てのオブジェクト型で toString などの Object.prototype の持つプロパティは暗黙的に継承されていることになっていて, プロパティアクセスが行えてしまう.

つまり以下のようなコードは型検査を通過するが, 実行してみるとエラーが発生する.

const obj = Object.groupBy([], () => "*");
obj.toString(); // => TypeError: w.toString is not a function

これまで prototype が null のオブジェクトが登場するような状況は (私の知る限り) かなり限られていたのであまり気にならなかったのだが, 今後は Object.groupBy の普及に伴ってしばしば登場するかもしれないので注意が必要そうである.

そもそもオブジェクトを辞書のように用いる (index signature を使う) のはいくらか型安全性に問題があるため, 多くの場合で Object.groupBy よりも Map.groupBy を使う方が適しているだろうということは覚えておきたい. また誤ったメソッドの呼び出しがもしあれば気がつけるように, ESLint の no-prototype-builtinsno-base-to-string といったルールも有効化しておくと良い.

ドメインモデリングにエフェクトを使う思考実験

TypeScript でエフェクトを使う話の続き. あるいは DI 手法の話でエフェクトを使うのを半ば冗談として書いていたのを, より具体的な状況を想定してもう少し真面目に考えてみる.

ドメイン層と永続化

ドメイン層においては永続化のための具体的な技術については関心を持ちませんが, 永続化すること自体に関心を持たないわけではありません. リポジトリというドメイン層に用意されたインターフェースを使って保存や検索を行いつつ, 具体的な技術はドメイン層の外にあるインターフェースの実装に任せるというのが典型例です. これは永続化以外にも, 例えばイベントの送信などについても同様のことが言えます. (なぜドメイン層では具体的な技術に関心を持たないかというと, ドメインが先で技術が後 (ドメイン駆動) という依存の方向を強制したいからです.)

具体例として, ツイート (†2023) を投稿するメソッドを考えてみましょう.

オブジェクトは以下のようなものが登場します.

// ツイート
type Tweet = {
  id: string;
  text: string;
  postedAt: Date;
};

// ツイート投稿時の入力
type PostTweetInput = {
  text: string;
};

// ツイートが投稿されたことを知らせるイベント
type TweetPostedEvent = {
  type: "tweetPosted",
  tweetId: string;
  text: string;
  postedAt: Date;
};

まずは素朴にツイートの投稿処理 (ツイートを保存し, イベントを送信する) を実装してみると, 例えば以下のようになると思います.

async function postTweet(input: PostTweetInput): Promise<void> {
  // ツイートを保存
  const tweet: Tweet = {
    id: idGenerator.next(),
    text: input.text,
    postedAt: clock.now(),
  };
  await db.exec(
    "INSERT INTO tweets (id, text, posted_at) VALUES (?, ?, ?)",
    [tweet.id, tweet.text, tweet.postedAt]
  );

  // イベントを送信
  const event: TweetPostedEvent = {
    type: "tweetPosted",
    tweetId: tweet.id,
    text: tweet.text,
    postedAt: tweet.postedAt,
  };
  await redis.publish("events", JSON.stringify(event));
}

これは RDB や Redis といった具体的な技術に依存してしまっているのであまりよくありません. ということでドメインモデルとしてはより抽象的な TweetStore, EventStream といったインターフェースを考えて, 具体的な技術のことは取り扱わないことにします.

// インターフェースの定義は省略

async function postTweet(input: PostTweetInput): Promise<void> {
  // ツイートを保存
  const tweet: Tweet = {
    id: idGenerator.next(),
    text: input.text,
    postedAt: clock.now(),
  };
  await tweetStore.insert(tweet);

  // イベントを送信
  const event: TweetPostedEvent = {
    type: "tweetPosted",
    tweetId: tweet.id,
    text: tweet.text,
    postedAt: tweet.postedAt,
  };
  await eventStream.publish(event);
}

実際にプログラムを実行するときには, tweetStore などに具体的な実装を注入 (DI) して実行することになります. この DI については様々な方法がありますが, DI のためにクラスを使ったりアノテーションを書いたりといった追加の記述が必要になることが普通です.

インターフェースの代わりにエフェクトを使う

上記の例では「ツイートを永続化するためのデータストア」がドメイン層に存在するといった形でドメインを記述しましたが, 少し見方を変えて, ドメイン層のビジネスロジックが「ツイートを永続化する」という効果 (エフェクト) を持つという形でもドメインを記述できそうです.

というわけで具体的には以下のようになります. ここで tweetStore.insert() などは呼び出された時点で副作用を発生させる関数ではなく, エフェクトのコンストラクタになっています.

import type { Effectful } from "@susisu/effectful";

// エフェクトの定義は省略

function* postTweet(
  input: PostTweetInput,
): Effectful<"tweetStore" | "eventStream", void> {
  // ツイートを保存
  const tweet: Tweet = {
    id: idGenerator.next(),
    text: input.text,
    postedAt: clock.now(),
  };
  yield* tweetStore.insert(tweet);

  // イベントを送信
  const event: TweetPostedEvent = {
    type: "tweetPosted",
    tweetId: tweet.id,
    text: tweet.text,
    postedAt: tweet.postedAt,
  };
  yield* eventStream.publish(event);
}

このようにエフェクトを発生させる関数として記述したビジネスロジックは, 他のビジネスロジックからは yield* postTweet({ text: "Hello" }) のように呼び出せます. また実際にプログラムを実行するときは, 以下のように適当な関数を用意して, エフェクトを具体的な技術を使った操作に翻訳してやります.

import { run } from "@susisu/effectful";

function runDomain<T>(
  program: Effectful<"tweetStore" | "eventStream" | ..., T>
): Promise<T> {
  return run(program, x => Promise.resolve(x), {
    tweetStore: (eff, resume) => {
      switch (eff.data.type) {
        case "insert": {
          const tweet = eff.data.tweet;
          return db.exec(
            "INSERT INTO tweets (id, text, posted_at) VALUES (?, ?, ?)",
            [tweet.id, tweet.text, tweet.postedAt]
          ).then(() => resume(eff.data.ev(undefined)));
        }
        // case ...
      }
    },
    eventStream: (eff, resume) => {
      switch (eff.data.type) {
        case "publish": {
          const event = eff.data.event;
          return redis.publish("events", JSON.stringify(event))
            .then(() => resume(eff.data.ev(undefined)));
        }
        // case ...
      }
    },
  });
}

runDomain(postTweet({ text: "Hello" }));

この方法の良いところは, 従来の DI と同様の実装の注入が自然に実現できながらも, そのための追加の記述をドメイン層に必要としないところです. エフェクトはもはやドメインにとって本質的な概念で, これ自体がドメインの記述のための道具であり, 過不足がありません.

追記: 非同期処理であるかどうかもドメインの記述にとって本質的ではないはずですが, これもドメインの外に追いやれていますね.

まとめ

  • ドメインモデリングにおいて, 具体的な技術を抽象化するインターフェースの代わりに, エフェクトを基本的な概念として導入することができそう
  • (TypeScript で) ドメイン層に対して DI を行うことを考えると, ドメインの記述に余計な要素が少なくなるという点で, 従来のインターフェース + DI よりもエフェクトの方が優れているのではないか

ここまで全部机上の議論なので, なんらか実践したりしてアップデートがあったらまた記事を書きます.