ゴール:「あとで async にしたくなっても困らない関数設計」を身につける
いきなり全部 async/await にする必要はありません。
むしろ大事なのは、
「今は同期処理だけど、
いつでも非同期(async)に“安全に”移行できる形で関数を設計しておく」
という発想です。
ここでは、
- async 関数になると「何が変わるのか」
- その変化を見越して、今からどう関数を設計しておくと楽になるか
を、具体例を交えながら整理していきます。
前提整理:async 関数になると何が起きるのか
戻り値が「T」から「Promise<T>」に変わる
TypeScript で async を付けると、
その関数の戻り値は必ず Promise<…> になります。
function syncGetUser(): User {
return { id: 1, name: "Alice" };
}
async function asyncGetUser(): Promise<User> {
return { id: 1, name: "Alice" };
}
TypeScript中身がまったく同じでも、async を付けた瞬間に「戻り値の型」が変わります。
呼び出し側も変わります。
const u1 = syncGetUser(); // User
const u2 = await asyncGetUser(); // User(ただし await が必要)
TypeScriptここが「非同期化したときに一番効いてくるポイント」です。
「今すぐ値」から「あとで値」に変わる
同期関数は「呼び出した瞬間に値が返る」のが前提です。
非同期関数は「今は Promise を返しておいて、
中身の値は“あとで”解決される」という世界になります。
この違いを、設計の段階で意識しておくと、
あとから async に変えるときのダメージがかなり減ります。
ステップ1:戻り値を「値」中心で設計しておく
副作用ベタ書き関数は、非同期化すると一気に苦しくなる
例えば、こういう関数を考えます。
function saveUser(user: User): void {
db.save(user); // 直接 DB に書き込む
console.log("saved"); // ログもここで出す
}
TypeScriptこの関数を「あとで非同期にしたくなった」とします。
async function saveUser(user: User): Promise<void> {
await db.save(user); // 非同期になったと仮定
console.log("saved");
}
TypeScript一見いけそうですが、呼び出し側が全部変わります。
saveUser(user); // 同期版
await saveUser(user); // 非同期版(await 必須)
TypeScriptしかも、戻り値が void なので、
「保存結果」や「エラー情報」を戻り値で扱う余地がありません。
ここで効いてくるのが、
「最初から“戻り値で結果を返す設計”にしておく」
という発想です。
「結果オブジェクト」を返す設計にしておく
例えば、こうしておきます。
type SaveResult =
| { ok: true }
| { ok: false; error: string };
function saveUser(user: User): SaveResult {
try {
db.save(user);
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
TypeScript呼び出し側はこう書きます。
const result = saveUser(user);
if (!result.ok) {
console.error(result.error);
}
TypeScriptこれを非同期にしたくなったとき、
関数の「形」はほとんど変えずに済みます。
async function saveUser(user: User): Promise<SaveResult> {
try {
await db.save(user); // 非同期になっただけ
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
TypeScript呼び出し側は await を足すだけです。
const result = await saveUser(user);
if (!result.ok) {
console.error(result.error);
}
TypeScript重要なのは、
「非同期化しても“戻り値の中身の型”は変えない」
「変わるのは T → Promise<T> だけ」
という設計にしておくことです。
ステップ2:関数の「境界」を意識しておく
外から見えるインターフェースを先に決める
非同期化で一番痛いのは、
「外から呼ばれている関数のシグネチャが変わること」です。
なので、まずは「外から見える顔」をインターフェースや型エイリアスで固定しておくと、あとが楽になります。
type GetUser = (id: number) => User;
const getUser: GetUser = (id) => {
return { id, name: "Alice" };
};
TypeScriptこれを非同期にしたくなったら、
インターフェースごと変えます。
type GetUserAsync = (id: number) => Promise<User>;
const getUserAsync: GetUserAsync = async (id) => {
const user = await fetchUserFromApi(id);
return user;
};
TypeScript「同期版の型」と「非同期版の型」を分けておくことで、
どこからどこまでが非同期に変わったのかが見えやすくなります。
「ここから先は全部 async」の境界を決める
実務では、
「この層(たとえばリポジトリ層)から下は全部 async」
のように、非同期の境界を決めることが多いです。
例えば、
ドメイン層の関数は同期的なインターフェースを保つ。
インフラ層(DB や API 呼び出し)は最初から async にしておく。
// ドメイン層(できるだけ同期的に保つ)
type CalculatePrice = (items: Item[]) => Price;
// インフラ層(最初から async)
type FetchItems = () => Promise<Item[]>;
TypeScriptこうしておくと、
「インフラ層の実装を変えても、
ドメイン層の関数シグネチャはできるだけ守る」
という方針が取りやすくなります。
ステップ3:エラー設計を「Promise 前提」で考えておく
throw だけに頼ると、非同期で扱いづらくなる
同期関数では、
「ダメなら throw でいいや」となりがちです。
function parseUser(json: string): User {
const data = JSON.parse(json);
if (!data.name) {
throw new Error("name is required");
}
return { id: data.id, name: data.name };
}
TypeScriptこれを非同期にしたとき、async 関数内の throw は「Promise の reject」になります。
async function parseUserAsync(json: string): Promise<User> {
const data = JSON.parse(json);
if (!data.name) {
throw new Error("name is required"); // Promise<User> が reject される
}
return { id: data.id, name: data.name };
}
TypeScript呼び出し側は try/catch で囲む必要が出てきます。
try {
const user = await parseUserAsync(json);
} catch (e) {
console.error("parse failed", e);
}
TypeScript「Result 型で返す」か「throw で返す」かを最初に決める
非同期にする前から、
「この関数は Result 型で返すのか」
「それとも throw で失敗を表すのか」
を決めておくと、あとで迷いません。
Result 型で返すなら、同期も非同期も同じ形です。
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseUser(json: string): Result<User, string> {
try {
const data = JSON.parse(json);
if (!data.name) {
return { ok: false, error: "name is required" };
}
return { ok: true, value: { id: data.id, name: data.name } };
} catch {
return { ok: false, error: "invalid json" };
}
}
async function parseUserAsync(json: string): Promise<Result<User, string>> {
return parseUser(json); // 中身はそのまま使える
}
TypeScript「エラーを戻り値で表現する設計」にしておけば、
非同期化しても「Promise で包むだけ」で済みます。
ステップ4:今からできる「非同期化に強い書き方」
1. 戻り値をちゃんと設計する(void に逃げない)
「とりあえず void」ではなく、
「この関数の“結果”を表す型」をちゃんと作っておく。
そうしておくと、T → Promise<T> に変えるだけで非同期化できます。
2. 関数の型を type / interface で表現しておく
関数をその場で書くだけでなく、
「この関数は (A) => B という型だ」と名前を付けておく。
type GetUser = (id: number) => User;
type GetUserAsync = (id: number) => Promise<User>;
TypeScriptこうしておくと、
「どこからどこまでが非同期版に差し替わったか」が見えやすくなります。
3. エラーの扱い方を統一しておく
- Result 型で返すのか
- throw で返すのか
どちらにするかを、プロジェクト内である程度揃えておくと、
非同期化したときも迷いません。
特に外部 API や DB まわりは、
「Promise<Result<…>>」のように
「非同期+結果オブジェクト」の形にしておくと、
後からの変更に強くなります。
まとめ:「非同期関数への移行準備」を自分の言葉で言うと
最後に、あなた自身の言葉でこう整理してみてください。
async にすると、
- 戻り値が
TからPromise<T>に変わる - 呼び出し側に
awaitやthenが必要になる
だからこそ、
- 最初から「戻り値の中身の型(T)」をちゃんと設計しておく
- 関数の型を type / interface で表現しておく
- エラーを Result 型で返すか、throw にするかを決めておく
といった準備をしておくと、
「同期 → 非同期」の移行は、
“ロジックを書き換える大工事”ではなく、
“戻り値を Promise で包む小さな変更”
で済むようになっていきます。
今書いている小さな関数を見ながら、
「これ、もし async にしたくなったら何が変わる?」
と一度イメージしてみてください。
その一呼吸が、
あなたの関数設計を「非同期に強い形」に育てていきます。
