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
に登録されており、userId
、 adminId
、scopes
などを引数に取り、それぞれ異なる戻り値を返します。
そして、次のように複数の認可ロジックを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.granted
はboolean
というように、それぞれの戻り値の型が正確に推論されることが理想です。
実装
まず、実行する関数はすべて識別子で指定できるようにし、定数でまとめて管理します。
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 User
はUser
型のキーname
とage
を順に走査するので、ReadonlyUser
の各プロパティは元のUser
のnam
eと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