コンテンツにスキップ

ロジック@TypeScript

はじめに

本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。


01. 実装スタイル

オブジェクト指向型

振る舞いは内部に状態を持つ。

副作用はあってもなくともよい(同じ入力の時に、出力は同じでも異なってもよい)。

状態と振る舞いが結合している。

ほかの型と共存できる。

class User {
  // オブジェクトの状態を設定する
  constructor(
    public name: string,
    public age: number,
  ) {}

  // 振る舞い
  isAdult(): boolean {
    return this.age >= 18;
  }
}

const user = new User("Alice", 20);
console.log(user.isAdult()); // true


関数型

振る舞いは状態をもたず、外部から状態を注入する。

副作用をなくす必要がある(同じ入力であれば、出力も同じである)。

状態と振る舞いが分離している。

ほかの型と共存できる。

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

// 振る舞い
function isAdult(user: User): boolean {
  return user.age >= 18;
}

// オブジェクトの状態を設定する
const u: User = {name: "Alice", age: 20};
console.log(isAdult(u)); // true


手続型

振る舞いは存在せず、状態が変化していく。

実装例以外に、反復で数値が増加していく処理も手続型である。

ほかの型と共存できる。

// オブジェクトの状態を設定する
let name = "Alice";
let age = 20;

// 手続き的に途中で状態を変更する
let isAdult = false;

if (age >= 18) {
  isAdult = true;
} else {
  isAdult = false;
}

console.log(isAdult); // true


02. 関数の種類

即時関数

無名関数の宣言と呼び出しを同時に行う。

// 無名関数を即時実行し、message変数に代入する
const message = (() => {
  return `Hello, Hiroki`;
})();

console.log(message);


03. 引数

分割代入引数

▼ 分割代入引数とは

関数の引数を定義するときに、オブジェクトから特定のプロパティを直接取り出する記法。

可読性が高くなる。

▼ 分割代入引数しない場合

function greet(
  // パラメーターからnameプロパティとuserプロパティを直接取り出さない
  user: {
    name: string;
    age: number;
  },
) {
  console.log(`Hello, ${user.name}. You are ${user.age} years old.`);
}

const user = {name: "Hiroki", age: 1};

// 引数を渡す
greet(user);

▼ 分割代入引数する場合

function greet(
  // パラメーターからnameプロパティとuserプロパティを直接取り出す
  {
    name,
    age,
  }: {
    name: string;
    age: number;
  },
) {
  console.log(`Hello, ${name}. You are ${age} years old.`);
}

const user = {name: "Hiroki", age: 1};

// 引数を渡す
greet(user);


04. エラーハンドリング

独自エラーオブジェクトの定義

ステータスコードに応じたエラーを継承すると、try-catchで扱いやすくなる。

export class NotFoundError extends Error {
  status: number;

  constructor(message = "The Requested URL was not found on this server") {
    super(message);
    this.name = "NotFoundError";
    this.status = 404;
  }
}


エラーメッセージの取得

TypeScriptでは、エラーの構造がさまざまある。

型安全のために、これらを条件分岐で処置する必要がある。

// Remixの場合
import {Response} from "@remix-run/node";

/**
 * エラーの構造に応じてエラーメッセージを取得する
 */
export async function getErrorMessage(error: unknown): Promise<string> {
  // RemixのResponseオブジェクトの場合
  // 例:スローしたjson関数によるエラーを捕捉した場合
  if (error instanceof Response) {
    try {
      const body = await error.json();
      if (typeof body?.message === "string") {
        return body.message;
      }
      if (typeof body?.error === "string") {
        return body.error;
      }
      if (error.statusText) {
        return error.statusText;
      }
      return "An unexpected error occurred.";
    } catch {
      if (error.statusText) {
        return error.statusText;
      }
      return "An unexpected error occurred.";
    }
  }

  // Typescript組み込みのErrorオブジェクトの場合
  // 例:Remixの内部的なエラーを捕捉した場合
  if (error instanceof Error) {
    if (error.message) {
      return error.message;
    }
    return "An unexpected error occurred.";
  }

  // その他の場合
  // 例:null、string、number、objectなどの想定外のエラーを捕捉した場合
  return String(error);
}
// Remixの場合
export const action = async ({request, params}: ActionArgs) => {
  try {
    // リクエストハンドリング
  } catch (error) {
    const errorMessage = await getErrorMessage(error);
    console.error("An error occurred:", errorMessage);
  }
};


05. コピー

参照コピー

変数がオブジェクト型の場合に、メモリアドレスをコピーする。

変数がプリミティブ型の場合、ポインタコピーは存在しない。

const original = {id: 1, name: "foo"};
const copy = original; // ポインタコピー
copy.name = "bar";

console.log(original.name); // "bar" となり、コピー前の状態が変わっている


シャローコピー

変数がオブジェクト型の場合に、変数の階層の値のみをコピーする。

ただし、第一階層だオブジェクト型であると参照コピーの挙動になる。

const original = {id: 1, fullname: {lastname: "foo", firstname: "foo"}};
const copy = {...original}; // シャローコピー
copy.fullname.firstname = "bar";

console.log(original.fullname.firstname); // "bar" となり、コピー前の状態が変わっている


ディープコピー

変数がオブジェクト型の場合に、変数の階層の値のみをコピーする。

const original = {id: 1, fullname: {lastname: "foo", firstname: "foo"}};
const copy = structuredClone(original);
copy.fullname.firstname = "bar";

console.log(original.fullname.firstname); // "foo" となり、コピー前の状態には影響がない


06. 条件式

二項演算子

与えられた変数の値がfalsyだった場合に、値を設定する。

デフォルト値を設定する場合に役立つ。

// ""、0、null、undefined、falseなど
const flag = enableFoo || "false";
// null、undefinedなど
const flag = enableFoo ?? "false";


三項演算子

// fooの場合にfooを代入し、fooでない場合にbarを代入する
const fooOrBar = foo == "foo" ? foo : "bar";


07. さまざまなプラクティスのまとめ

// エラーハンドリング: Result型で値を型として明示
type Result<T, E> = {ok: true; value: T} | {ok: false; error: E};
type UserFetchError = "UNAUTHORIZED" | "RATE_LIMITED" | "INTERNAL_ERROR";

// 保守性: TypeScriptでAPIレスポンスの型安全性を確保
type UserResponse = {
  id: string;
  name: string;
};

async function getUserNames(
  // パフォーマンス: 複数ユーザーIDをまとめて取得することで、N+1問題を回避
  userIds: string[],
  // テストビリティ: 依存性注入により、モックに差し替えできるようにする
  di: DI,
): Promise<Result<Map<string, string>, UserFetchError>> {
  // 信頼性: リトライ機構により、一時的な障害に対応
  const maxRetries = 3;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    // 信頼性: タイムアウトにより、ネットワーク遅延からアプリケーションを保護
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    try {
      // 依存性注入された親オブジェクトから子オブジェクトを取り出す
      // 実環境では本物のfetchClientを使用し、テスト環境ではモック用のfakeFetchClientを使用する
      const response = await di.fetchClient(
        // スケーラビリティ: バッチ処理でリクエスト回数を最小化
        // 保守性: URLのベタ書きを排除
        `${di.apiBaseUrl}/users/batch`,
        {
          method: "POST",
          headers: {
            // 認証・認可: JWTでAPIを保護
            // 依存性注入された親オブジェクトから子オブジェクトを取り出す
            Authorization: `Bearer ${await di.jwtProvider()}`,
            "Content-Type": "application/json",
          },
          // パフォーマンス: 複数ユーザーIDをまとめて取得することで、N+1問題を回避
          // パフォーマンス: 必要なフィールドのみ取得してデータ転送量を最小化
          body: JSON.stringify({ids: userIds, fields: ["id", "name"]}),
          signal: controller.signal,
        },
      );

      // エラーハンドリング: HTTPステータスを適切にハンドリング
      if (!response.ok) {
        if (response.status >= 400 && response.status < 500) {
          switch (response.status) {
            // 認証・認可: APIを保護
            case 403:
              throw new NonRetryableError("UNAUTHORIZED", response.status);
            // スケーラビリティ: レート制限対応
            case 429:
              throw new NonRetryableError("RATE_LIMITED", response.status);
            default:
              throw new NonRetryableError("INTERNAL_ERROR", response.status);
          }
        }
        throw response;
      }

      const users = await response.json();

      // セキュリティ: レスポンスを厳密に検証
      if (!isValidUsersResponse(users)) {
        throw new NonRetryableError("INTERNAL_ERROR");
      }

      const results = new Map(users.map((user) => [user.id, user.name]));
      return {ok: true, value: results};
    } catch (error) {
      // 依存性注入された親オブジェクトから子オブジェクトを取り出す
      // 可観測性: 構造化されたログ
      di.logger.error("Failed to fetch users", {
        userIds,
        attempt,
        error,
      });
      // 依存性注入された親オブジェクトから子オブジェクトを取り出す
      // 可観測性: メトリクス
      di.metrics.increment("user_fetch_error", {
        status:
          error instanceof Response || error instanceof NonRetryableError
            ? error.status
            : undefined,
      });

      if (error instanceof NonRetryableError) {
        return {ok: false, error: error.errorType};
      }

      if (attempt < maxRetries) {
        // 信頼性: 指数バックオフ(1秒→2秒→4秒)によるリトライで過負荷を防止
        const delay = 2 ** attempt * 1000;
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    } finally {
      clearTimeout(timeoutId);
    }
  }

  return {ok: false, error: "INTERNAL_ERROR"};
}