💪

Tuple と Mapped Types を活用して、関数の型推論を極める

に公開

はじめに

最近、業務で認可処理を行うユーティリティを開発している中で、複数の関数を実行し、その返り値を型安全に取得する必要がありました。
本記事では、Tuple型とMapped Typesを活用して、複数の関数を1つの仕組みに統合しつつ、戻り値の型推論を壊さない設計パターンを紹介します。

具体例

例えば、以下のような関数群があるとします。

const AuthorizerFunctions = {
  getUser: async ({ userId }: { userId: number }) => {
    return { user: { id: userId, name: 'Alice' } };
  },

  getAdmin: async ({ adminId }: { adminId: number }) => {
    return { admin: { id: adminId, role: 'admin' } };
  },

  checkScope: async ({ scopes, allowed }: { scopes: string[]; allowed: string[] }) => {
    return { granted: scopes.some((s) => allowed.includes(s)) };
  },
} as const;

各関数はAuthorizerFunctionsに登録されており、userIdadminIdscopesなどを引数に取り、それぞれ異なる戻り値を返します。

そして、次のように複数の認可ロジックを1回で呼びたいとします。

const [userRes, adminRes, scopeRes] = await authorizer([
  {
    authorizer: Authorizers.GetUser,
    payload: { userId: 1 },
  },
  {
    authorizer: Authorizers.GetAdmin,
    payload: { adminId: 42 },
  },
  {
    authorizer: Authorizers.CheckScope,
    payload: {
      scopes: ['read', 'write'],
      allowed: ['read', 'delete'],
    },
  },
]);

ここで、

  • userRes.user{ id: number; name: string }
  • adminRes.admin{ id: number; role: string }
  • scopeRes.grantedboolean

というように、それぞれの戻り値の型が正確に推論されることが理想です。

実装

まず、実行する関数はすべて識別子で指定できるようにし、定数でまとめて管理します。

export const Authorizers = {
  GetUser: 'getUser',
  GetAdmin: 'getAdmin',
  CheckScope: 'checkScope',
} as const;

const AuthorizerFunctions = {
  [Authorizers.GetUser]: async ({ userId }: { userId: number }) => {
    return { user: { id: userId, name: 'Alice' } };
  },

  [Authorizers.GetAdmin]: async ({ adminId }: { adminId: number }) => {
    return { admin: { id: adminId, role: 'super-admin' } };
  },

  [Authorizers.CheckScope]: async ({
    scopes,
    allowed,
  }: {
    scopes: string[];
    allowed: string[];
  }) => {
    return { granted: scopes.some((s) => allowed.includes(s)) };
  },
} as const;

type Authorizers =
  (typeof Authorizers)[keyof typeof Authorizers];

このように識別子を通じて関数を指定する設計にすることで、関数名とキーを分離して管理でき、利用側で安全に識別できます。

型推論付きで関数を呼び出すauthorizer関数を定義

ここからが本題です。
入力の配列に応じて、関数ごとの型が正しく推論されるように設計します。

export const authorizer = async <
  T extends Authorizers[],
>(authorizers: {
  [P in keyof T]: {
    authorizer: T[P];
    payload: Parameters<(typeof AuthorizerFunctions)[T[P]]>[0];
  };
}): Promise<{
  [P in keyof T]: Awaited<ReturnType<(typeof AuthorizerFunctions)[T[P]]>>;
}> => {
  const results = await Promise.allSettled(
    authorizers.map(({ authorizer, payload }) => {
      switch (authorizer) {
        case Authorizers.GetUser: {
          const { userId } = payload as Parameters<
            (typeof AuthorizerFunctions)[typeof Authorizers.GetUser]
          >[0];

          return AuthorizerFunctions[Authorizers.GetUser]({ userId });
        }
        case Authorizers.GetAdmin: {
          const { adminId } = payload as Parameters<
            (typeof AuthorizerFunctions)[typeof Authorizers.GetAdmin]
          >[0];

          return AuthorizerFunctions[Authorizers.GetAdmin]({ adminId });
        }
        case Authorizers.CheckScope: {
          const { scopes, allowed } = payload as Parameters<
            (typeof AuthorizerFunctions)[typeof Authorizers.CheckScope]
          >[0];

          return AuthorizerFunctions[Authorizers.CheckScope]({ scopes, allowed });
        }
        default:
          authorizer satisfies never;
      }
    }),
  );

  return results as {
    [P in keyof T]: Awaited<ReturnType<(typeof AuthorizerFunctions)[T[P]]>>;
  };
};

ここでポイントとなるのがMapped Types × Tuple × 型取得ユーティリティの併用です。

  • T extends Authorizers[] → 関数名のリスト(Tuple)を型として取得
  • [P in keyof T] → 入力配列の順番通りに型を構築
  • Parameters<F>[0] → 関数の第1引数の型を取得
  • ReturnType<F> + Awaited → 関数の戻り値型を非同期対応で取得
Tuple と Mapped Types の簡単な解説

Tuple(タプル)

配列型の一種で、各要素の型と数が決まっている配列です。

const example: [string, number] = ["Alice", 30];

// 結果: example[0] → "Alice" (string型)
// 結果: example[1] → 30 (number型)

配列の1番目はstring、2番目はnumberになっており、この順番で型が保証されています。
配列の構造とその中の型を厳密に制御したいときに使います。

Mapped Types(マップ型)

あるオブジェクト型や配列型の各キーに基づいて新しい型を生成する仕組み。

type User = {
  name: string;
  age: number;
};

type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

// 結果: { readonly name: string; readonly age: number; }

K in keyof UserUser型のキーnameageを順に走査するので、ReadonlyUserの各プロパティは元のUsernameとageの型にreadonlyを付けた新しい型になります。

使用例

const [userRes, adminRes, scopeRes] = await authorizer([
  {
    authorizer: Authorizers.GetUser,
    payload: { userId: 1 },
  },
  {
    authorizer: Authorizers.GetAdmin,
    payload: { adminId: 42 },
  },
  {
    authorizer: Authorizers.CheckScope,
    payload: {
      scopes: ['read', 'write'],
      allowed: ['read'],
    },
  },
]);

userResの型

{
  user: {
    id: number;
    name: string;
  };
}

adminResの型

{
  admin: {
    id: number;
    role: string;
  };
}

scopeResの型

{
  granted: boolean;
}

これらはすべてauthorizer()関数の戻り値から順序通りにタプルとして受け取り、各関数の戻り値型が自動で展開されている結果です。

まとめ

今回紹介したパターンは、「関数の動的な呼び分け」と「型推論の精度保持」を両立させる設計です。

コードの再利用性や保守性も高くなるので、認可処理だけでなく、APIラッパーやバリデーションユーティリティなどにも応用できると思います!

ユニフォームネクスト株式会社

Discussion