クラスを関数に書き換える in TypeScript

2022/04/24に公開

お題

今回扱うのは以下のクラスです。

export class Path {
  constructor(private pathString: string) {}

  toString() {
    return this.pathString;
  }

  fillIds(ids: { [id: string]: string | number }) {
    let _pathString = this.pathString;
      for (const id in ids) {
      _pathString = _pathString.replace(`:${id}`, ids[id].toString());
    }
    return new Path(_pathString);
  }

  appendQueries(queries: { [query: string]: string | number }) {
    const queryString = Object.keys(queries)
      .map((key) => `${key}=${queries[key]}`)
      .join("&");
    if (this.pathString.includes("?")) {
      return new Path(`${this.pathString}&${queryString}`);
    }
    return new Path(`${this.pathString}?${queryString}`);
  }
}

path 情報を管理するクラスで、'/resource/:resourceId' 形式の path 文字列の操作と出力が可能です。以下が実行例になります。

const userVideoPagePath = new Path('/user/:userId/video/:videoId');

userVideoPagePath.toString(); // '/user/:userId/video/:videoId'

userVideoPagePath.fillIds({
  userId: 1000,
  videoId: 30,
}).toString(); // '/user/1000/video/30'

userVideoPagePath.fillIds({
  userId: 2000,
  videoId: 10,
}).appendQueries({
  timestamp: 1650753766,
}).toString(); // '/user/2000/video/10?timestamp=1650753766'

toString() 以外のクラスメソッドは新たな Path インスタンスを返します。これは method chaine が fluent interface になるように意識しています。

関数化

Path を関数型プログラミングのテクニックを使って書き換えたものがこちらです。

const pathGenerator = (pathString: string) =>
  ({
    generate: () => pathString,
    fillIds: (ids: { [idName: string]: string | number }) => {
      let _pathString = pathString;
      for (const idName in ids) {
        _pathString = _pathString.replace(`:${idName}`, ids[idName].toString());
      }
      return pathGenerator(_pathString);
    },
    appendQueries: (queries: { [queryName: string]: string | number }) => {
      const queryString = Object.keys(queries)
        .map((queryName) => `${queryName}=${queries[queryName]}`)
        .join("&");
      if (pathString.includes("?")) {
        return pathGenerator(`${pathString}&${queryString}`);
      }
      return pathGenerator(`${pathString}?${queryString}`);
    },
  } as const);

与えられた pathString の値を保持し、各関数で遅延評価して新たな pathGenerator を返します。なんとなく Generator かなと思ってこのような名前にしたんですが、関数型プログラミングの慣例的にもっと適した名前があれば教えてください。

実行例は以下になります。

const userVideoPagePathGenerator = pathGenerator('/user/:userId/video/:videoId');

userVideoPagePathGenerator.generate(); // '/user/:userId/video/:videoId'

userVideoPagePathGenerator.fillIds({
  userId: 1000,
  videoId: 30,
}).generate(); // '/user/1000/video/30'

userVideoPagePathGenerator.fillIds({
  userId: 2000,
  videoId: 10,
}).appendQueries({
  timestamp: 1650753766,
}).generate(); // '/user/2000/video/10?timestamp=1650753766'

再代入しないように修正

fillIds() の中で for 文を利用しているため再代入が必要になっています。let を使っているのがイマイチなので for 文を使わない書き方に修正します。

配列(Array.map())

配列の関数を利用した方法は以下です。

const pathGenerator = (pathString: string) => {
  generate: () => {/* 省略 */},
  fillIds: (ids: { [idName: string]: string | number }) => {
    const replacedPathString = pathString
      .split('/')
      .map(path => {
        if(path[0] === ':') {
          const idName = path.slice(1);
          return ids[idName];
        }
        return path;
      }).join('/');
    return pathGenerator(replacedPathString)
  },
  appendQueries: () => {/* 省略 */},
}

再帰

再帰を利用した書き方は以下です。配列を利用した方法に比べて記述量が多く、読みづらい印象があります。

type PathGenerator = {
  generate: () => string;
  fillIds: (ids: { [idName: string]: string | number }) => PathGenerator;
  appendQueries: (queryies: {
    [queryName: string]: string | number;
  }) => PathGenerator;
};

const pathGenerator = (pathString: string): PathGenerator => {
  generate: () => {/* 省略 */},
  fillIds: (ids: { [idName: string]: string | number }) => {
    const [firstIdName] = Object.keys(ids);
    if (!firstIdName) { // 再帰の終了条件
      return pathGenerator(pathString);
    }
    const { [firstIdName]: firstValue, ...restIds } = ids;
    const replacedPathString = pathString.replace(`:${firstIdName}`, ids[firstIdName].toString());
    return pathGenerator(replacedPathString).fillIds(restIds); // 末尾再帰
  },
  appendQueries: () => {/* 省略 */},
}

fillIds() が再帰関数を return しているため、type PathGenerator を使って型定義しないと型推論ができずエラーになります

型定義の分記載量が増えますが、推論される型は読みづらいので再帰でなくても自前の型定義があっても良いかなと思いました。

おまけ

関数型プログラミングとは関係が無いですが、pathString が / で始まるかどうかを型で明示すると便利そうです。

const pathGenerator = (pathString: `/${string}`): PathGenerator => {
  // 他の箇所も適宜修正

最終系

再帰を利用したやり方で記載しています。

type PathGenerator = {
  generate: () => `/${string}`;
  fillIds: (ids: { [id: string]: string | number }) => PathGenerator;
  appendQueries: (queryies: {
    [queryName: string]: string | number;
  }) => PathGenerator;
};

const pathGenerator = (pathString: `/${string}`): PathGenerator =>
  ({
    generate: () => pathString,
    fillIds: (ids: { [id: string]: string | number }) => {
      const [firstIdName] = Object.keys(ids);
      if (!firstIdName) {
        return pathGenerator(pathString);
      }
      const { [firstIdName]: firstValue, ...restIds } = ids;
      const replacedPathString = pathString.replace(
        `:${firstIdName}`,
        ids[firstIdName].toString()
      ) as `/${string}`;
      return pathGenerator(replacedPathString).fillIds(restIds);
    },
    appendQueries: (queries: { [queryName: string]: string | number }) => {
      const queryParams = Object.keys(queries)
        .map((key) => `${key}=${queries[key]}`)
        .join("&");
      if (pathString.includes("?")) {
        return pathGenerator(`${pathString}&${queryParams}`);
      }
      return pathGenerator(`${pathString}?${queryParams}`);
    },
  } as const);

まとめ

お題がすでに整い過ぎていたので、次やる場合はもっと面白くなるようなテーマを考えたい。

GitHubで編集を提案

Discussion