🎄

ECMAScript Private Fields / TypeScript一人カレンダー

2024/12/22に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の20日目です。昨日は『実例 UnknownifyDiscriminatedUnion』を紹介しました。

TypeScriptにおけるprivateとECMAScript Private Fields

TypeScriptには初期からprivateキーワードが存在しており、クラスのメンバに対してJavaのような「外部からアクセスできない」スコープを指定できます。protectedpublicも同様に、Javaなどによく似たアクセス制御を提供してきました。

class Foo {
  private bar = 123; // 従来からのプライベート宣言

  getBar(): number {
    return this.bar;
  }
}

console.log(new Foo().getBar()); // 123
console.log(new Foo().bar); // 123 ただしTypeScript上ではコンパイルエラー

一方で、ECMAScript Private Fieldsという仕様がかつてからTC39プロポーザルとして進められており、ES 2022で正式な言語仕様として合流しました。

ECMAScript Private Fieldsでは、クラスのプライベートメンバを#記法で宣言でき、実際のJavaScriptランタイム上で「外部から一切アクセスできないプライベート」を実現します。

class Foo {
  #bar = 123; // ECMAScript Private Fieldsとしてのプライベート宣言

  getBar(): number {
    return this.#bar;
  }
}

console.log(new Foo().getBar()); // 123
console.log(new Foo().bar); // undefined
console.log(new Foo().#bar); // SyntaxError

上記のように、#barと宣言するだけで、実行時レベルで保証される真のプライベートが得られます。TypeScriptのprivateはあくまでも型レベルのチェックでしたが、ECMAScript Private Fieldsは実際に処理系レベルで遮断されるため、より厳格な可視性を確保できます。

これは、コミュニティの中では「ハードプライベート」と呼ばれることがあります。それに対して、TypeScriptが初期から備えているprivateは「ソフトプライベート」と呼ばれることもあり、Webでの検索結果や技術記事などでそう表現されていれば、この辺りを指している可能性が高いです。

コンパイラ・ランタイムの対応状況と運用

ECMAScript Private Fieldsは、TypeScript上では2020年の3.8から利用できるようになっていましたが、当時は主要ブラウザやNode.jsランタイムの対応がまちまちだったこともあり、業務プロジェクトでは採用するか悩ましいものでした。

それから約4年が経ち、モダンブラウザや最新のNode.js環境では標準としてサポートされるようになっています。筆者の近年のプロジェクトでも、class定義を書くときは常に#によるプライベートフィールドを使い、TypeScriptのprivateキーワードを書かなくなりました。

実際の業務ではどう使う?

下記の例として、筆者のチームで使用しているテスト用のモックサーバ実装 MswServer クラスを示します。mswをより記述しやすくすることを目的としたラッパークラスです。

これはあくまで「ECMAScript Private Fieldsを使った、普通の業務コードの実装例が見たい」という声を想定した例であり、ECMAScript Private Fieldsに特化した何らかの利点を示すものではなく、ごくありふれたクラスあることをご了承ください。

export class MswServer {
  #server: ReturnType<typeof setupServer>; // ECMAScriptのprivateフィールド

  constructor() {
    this.#server = setupServer();
  }

  listen(): void {
    this.#server.listen();
  }

  resetHandlers(): void {
    this.#server.resetHandlers();
  }

  close(): void {
    this.#server.close();
  }

  get(path: string, handle: Handler): void {
    prepareServer(this.#server, "get", path, handle);
  }

  post(path: string, handle: Handler): void {
    prepareServer(this.#server, "post", path, handle);
  }

  put(path: string, handle: Handler): void {
    prepareServer(this.#server, "put", path, handle);
  }

  graphql(operationName: string, handle: ResolverHandler): void {
    prepareResolver(this.#server, operationName, handle);
  }
}

このクラスのインスタンスは、テストコード内でこのように使います。

const msw = new MswServer();

msw.get('/api/path/to', (req, res) => {
  parse(schema, req);
  res.status(200).json({ /* モックデータ */ });
});

msw.post('/api/path/to', (req, res) => {
  parse(schema, req);
  res.status(201).json({ /* モックデータ */ });
});

msw.put('/api/path/to', (_, res) => {
  res.status(400).text("Bad request");
});

これは、Express.jsとmswを併用しているVitest, Playwrightにて、それぞれのテストでモックサーバーの実装が多重保守になっている問題点を解決するために開発しています。ここで、Express.jsはそのまま実装、mswは今回のラッパークラスを経由して実装することで、Express.jsやNode.jsでよく見かけるAPIとほぼ同じインタフェースで両者を扱えるようにしており、実装の共通化による一元管理が可能となります。

もしこのクラスがTypeScriptのprivate server;としての宣言だった場合、コンパイル後のJavaScriptでは普通に msw.serverというプロパティとして外部から参照できてしまいますが、ECMAScript Private Fieldsを使えば実行時にmsw.#serverは記述自体が認められず秘匿が可能です。

見慣れないうちは「このシャープはなんだろう」と思われるかもしれませんが、あっという間に仕様化から2年が経っており、徐々に浸透していくかなと予想しています。

まとめ

TypeScript は初期から private キーワードによるソフトプライベートをサポートしており、多くの人が Java 等に似た感覚でクラスのメンバにアクセス制御を施してきました。しかし、正式に言語仕様化された Private Fields は、ランタイムレベルでのハードプライベートを実装する強力な仕組みです。いまや古いブラウザ・ランタイムを対象にしなくてもよい環境下では class を書く際に # と定義するメリットが大きいと言えます。

今回の MswServer の例でも、TypeScript の private ではなく自然とECMAScript Private Fieldsを採用しており「もはや private キーワードは使わなくなった」という状況です。もしまだECMAScript Private Fieldsを利用したことがないのであれば、型安全性だけでなく、実行時における外部からの秘匿という観点からも、プロジェクトで一度検討してみるのもよいでしょう。

おまけ サンプルコード全容

前述のサンプルコードは抜粋のため、そのまま動かそうとするといくつかサンプルとして提示していない内容が含まれています。場所を取ってしまうため章末に移しましたが、以下で全容を掲載します。

type Handler = (
  req: ExpressCompatibleRequest,
  res: ExpressCompatibleResponse,
) => Response;

type ResolverResponse = Awaited<
  ReturnType<Parameters<(typeof graphql)["query"]>[1]>
>;

type ResolverHandler = (variables: unknown) => Record<string, unknown>;

function prepareServer(
  server: ReturnType<typeof setupServer>,
  method: "get" | "post" | "put",
  path: string,
  handle: Handler,
): void {
  server.use(
    http[method](path, async (info): Promise<Response> => {
      const req = await transformReqStream(info);
      const res = new ExpressCompatibleResponse();
      try {
        return handle(req, res);
      } catch (e) {
        if (e instanceof Error) {
          console.error(e);
          // no return or throw
        }
        return HttpResponse.error();
      }
    }),
  );
}

function prepareResolver(
  server: ReturnType<typeof setupServer>,
  operationName: string,
  handle: ResolverHandler,
): void {
  server.use(
    graphql.query(operationName, async (info): Promise<ResolverResponse> => {
      try {
        const data = handle(info.variables);
        return HttpResponse.json({ data }, { status: 201 });
      } catch (e) {
        if (e instanceof ValiError) {
          console.error(flatten(e.issues));
          // no return or throw
        }
        throw e;
      }
    }),
  );
}
import type { http } from "msw";

export type ResponseResolverInfo = Parameters<
  Parameters<(typeof http)["all"]>[1]
>[0];
import type { ResponseResolverInfo } from "./response-resolver-info";

export type ExpressCompatibleRequest = Readonly<{
  url: string;
  method: string;
  headers: Headers;
  body: unknown;
}>;

export async function transformReqStream(
  info: ResponseResolverInfo,
): Promise<ExpressCompatibleRequest> {
  const body = await (async (): Promise<unknown> => {
    const reqText = await info.request.text();
    try {
      return JSON.parse(reqText) as unknown;
    } catch (e) {
      if (e instanceof SyntaxError) {
        return reqText;
      }
      throw e;
    }
  })();

  return {
    url: info.request.url,
    method: info.request.method,
    headers: info.request.headers,
    body,
  };
}
import { HttpResponse } from "msw";

type JsonParams = Parameters<(typeof HttpResponse)["json"]>[0];
type JsonReturn = ReturnType<(typeof HttpResponse)["json"]>;

type TextParams = Parameters<(typeof HttpResponse)["text"]>[0];
type TextReturn = ReturnType<(typeof HttpResponse)["text"]>;

export class ExpressCompatibleResponse {
  #code: number | null = null;

  status(code: number): this {
    this.#code = code;
    return this;
  }

  json(data: JsonParams): JsonReturn {
    return HttpResponse.json(data, { status: this.#code ?? 200 });
  }

  text(text: TextParams): TextReturn {
    return HttpResponse.text(text, { status: this.#code ?? 200 });
  }
}

ちなみに、ExpressCompatibleResponse#codeも同様ですね。

明日は『using』

本日は『ECMAScript Private Fields』を紹介しました。明日は『using宣言』を紹介します。それではまた。

Discussion