TypeScript | 関数・クラス・ジェネリクス:関数設計の深化 – 非同期関数への移行準備

TypeScript TypeScript
スポンサーリンク

ゴール:「あとで 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

重要なのは、

非同期化しても“戻り値の中身の型”は変えない
「変わるのは TPromise<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」ではなく、
「この関数の“結果”を表す型」をちゃんと作っておく。

そうしておくと、
TPromise<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> に変わる
  • 呼び出し側に awaitthen が必要になる

だからこそ、

  • 最初から「戻り値の中身の型(T)」をちゃんと設計しておく
  • 関数の型を type / interface で表現しておく
  • エラーを Result 型で返すか、throw にするかを決めておく

といった準備をしておくと、
「同期 → 非同期」の移行は、

“ロジックを書き換える大工事”ではなく、
“戻り値を Promise で包む小さな変更”

で済むようになっていきます。

今書いている小さな関数を見ながら、
「これ、もし async にしたくなったら何が変わる?」
と一度イメージしてみてください。

その一呼吸が、
あなたの関数設計を「非同期に強い形」に育てていきます。

タイトルとURLをコピーしました