T-CREATOR

関数型プログラミングと TypeScript:純粋関数で堅牢なコードを書く方法

関数型プログラミングと TypeScript:純粋関数で堅牢なコードを書く方法

関数型プログラミングは、数学的な関数の概念に基づいてプログラムを構築するパラダイムです。TypeScript の強力な型システムと組み合わせることで、より予測可能で保守しやすく、テストしやすいコードを書くことができます。この記事では、関数型プログラミングの基本概念から実践的な活用方法まで、TypeScript での実装を通じて詳しく解説します。

関数型プログラミングとは?

関数型プログラミングは、計算を数学的な関数の評価として捉えるプログラミングパラダイムです。状態の変更や可変データを避け、関数の合成によってプログラムを構築します。

関数型プログラミングの主な特徴

  1. 不変性(Immutability): データは一度作成されると変更されません
  2. 純粋関数(Pure Functions): 同じ入力に対して常に同じ出力を返し、副作用を持ちません
  3. 高階関数(Higher-Order Functions): 関数を引数として受け取ったり、関数を返したりする関数
  4. 関数合成(Function Composition): 小さな関数を組み合わせて複雑な処理を構築

TypeScript で関数型プログラミングを学ぶメリット

TypeScript の型システムは、関数型プログラミングの概念を表現するのに適しています:

typescript// 型安全な関数の定義
type MathOperation = (a: number, b: number) => number;

const add: MathOperation = (a, b) => a + b;
const multiply: MathOperation = (a, b) => a * b;

// 高階関数の型定義
type ApplyOperation = (
  op: MathOperation
) => (a: number, b: number) => number;

const createCalculator: ApplyOperation = (op) => (a, b) =>
  op(a, b);

純粋関数の基礎

純粋関数の定義と特徴

純粋関数は、関数型プログラミングの核となる概念です。以下の条件を満たす関数を純粋関数と呼びます:

  1. 決定性: 同じ入力に対して常に同じ出力を返す
  2. 副作用なし: 関数外部の状態を変更しない
typescript// 純粋関数の例
const pureAdd = (a: number, b: number): number => a + b;

const pureCalculateArea = (radius: number): number =>
  Math.PI * radius * radius;

// 文字列処理の純粋関数
const pureFormatName = (
  firstName: string,
  lastName: string
): string => `${firstName} ${lastName}`.trim();

副作用とは何か、なぜ避けるべきか

副作用とは、関数の主要な目的以外で発生する変更や影響のことです:

typescript// 副作用のある関数(避けるべき例)
let counter = 0;

const impureIncrement = (): number => {
  counter++; // 外部状態の変更(副作用)
  console.log(counter); // I/O操作(副作用)
  return counter;
};

// 純粋な代替案
const pureIncrement = (current: number): number =>
  current + 1;

// 副作用の分離
const logAndIncrement = (
  current: number
): [number, string] => {
  const newValue = pureIncrement(current);
  const logMessage = `Counter: ${newValue}`;
  return [newValue, logMessage];
};

TypeScript で表現する純粋関数

TypeScript の型システムを活用して、純粋関数をより安全に実装できます:

typescript// 型安全な純粋関数
type User = {
  readonly id: number;
  readonly name: string;
  readonly email: string;
};

// ユーザー情報の変換(純粋関数)
const formatUserDisplay = (user: User): string =>
  `${user.name} (${user.email})`;

// 配列処理の純粋関数
const filterActiveUsers = (
  users: readonly User[],
  activeIds: readonly number[]
): User[] =>
  users.filter((user) => activeIds.includes(user.id));

// 条件付き処理の純粋関数
const getDiscountedPrice = (
  price: number,
  hasDiscount: boolean
): number => (hasDiscount ? price * 0.9 : price);

イミュータビリティ(不変性)の実践

不変データ構造の作り方

TypeScript でイミュータブルなデータ構造を作成する方法:

typescript// 基本的な不変オブジェクト
type Product = {
  readonly id: number;
  readonly name: string;
  readonly price: number;
  readonly tags: readonly string[];
};

// 不変な更新関数
const updateProductPrice = (
  product: Product,
  newPrice: number
): Product => ({
  ...product,
  price: newPrice,
});

// ネストしたオブジェクトの不変更新
type Order = {
  readonly id: number;
  readonly customer: {
    readonly name: string;
    readonly email: string;
  };
  readonly items: readonly Product[];
};

const updateCustomerEmail = (
  order: Order,
  newEmail: string
): Order => ({
  ...order,
  customer: {
    ...order.customer,
    email: newEmail,
  },
});

Object.freeze vs readonly vs const assertions

typescript// Object.freeze - ランタイムでの不変性
const frozenObject = Object.freeze({
  name: 'John',
  age: 30,
});

// readonly - TypeScriptの型レベルでの不変性
type ReadonlyUser = {
  readonly name: string;
  readonly age: number;
};

// const assertions - より厳密な型推論
const userConfig = {
  theme: 'dark',
  language: 'ja',
  notifications: true,
} as const;

// 配列の不変性
const readonlyArray: readonly number[] = [1, 2, 3];
const constArray = [1, 2, 3] as const; // より厳密

Immutable.js や Immer との連携

typescript// Immerを使った不変更新
import { produce } from 'immer';

type State = {
  users: User[];
  selectedUserId: number | null;
  loading: boolean;
};

const addUser = (state: State, newUser: User): State =>
  produce(state, (draft) => {
    draft.users.push(newUser);
  });

const selectUser = (state: State, userId: number): State =>
  produce(state, (draft) => {
    draft.selectedUserId = userId;
  });

// カスタム更新ヘルパー
const updateInArray = <T>(
  array: readonly T[],
  predicate: (item: T) => boolean,
  updater: (item: T) => T
): T[] =>
  array.map((item) =>
    predicate(item) ? updater(item) : item
  );

高階関数と TypeScript

map、filter、reduce の型安全な活用

typescript// 型安全なmap操作
const numbers = [1, 2, 3, 4, 5] as const;
const squared = numbers.map((n): number => n * n);

// 型を変換するmap
type Person = { name: string; age: number };
type PersonSummary = { name: string; isAdult: boolean };

const people: Person[] = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 17 },
];

const summaries: PersonSummary[] = people.map((person) => ({
  name: person.name,
  isAdult: person.age >= 18,
}));

// 条件付きfilter
const adults = people.filter(
  (person): person is Person & { age: number } =>
    person.age >= 18
);

// 型安全なreduce
const totalAge = people.reduce(
  (sum, person) => sum + person.age,
  0
);

const groupByAdultStatus = people.reduce((acc, person) => {
  const key = person.age >= 18 ? 'adults' : 'minors';
  return {
    ...acc,
    [key]: [...(acc[key] || []), person],
  };
}, {} as Record<'adults' | 'minors', Person[]>);

カリー化(Currying)と Partial Application

typescript// カリー化の実装
const curry =
  <A, B, C>(fn: (a: A, b: B) => C) =>
  (a: A) =>
  (b: B) =>
    fn(a, b);

// 使用例
const add = (a: number, b: number): number => a + b;
const curriedAdd = curry(add);
const addFive = curriedAdd(5);

console.log(addFive(3)); // 8

// より複雑なカリー化
const createValidator =
  <T>(fieldName: string) =>
  (validation: (value: T) => boolean) =>
  (errorMessage: string) =>
  (value: T): { isValid: boolean; error?: string } => {
    const isValid = validation(value);
    return isValid
      ? { isValid: true }
      : {
          isValid: false,
          error: `${fieldName}: ${errorMessage}`,
        };
  };

const validateEmail = createValidator<string>('Email')(
  (email) => email.includes('@')
)('Invalid email format');

const result = validateEmail('test@example.com');

関数合成(Function Composition)

typescript// 基本的な関数合成
const compose =
  <A, B, C>(f: (b: B) => C, g: (a: A) => B) =>
  (a: A): C =>
    f(g(a));

// パイプライン風の合成
const pipe =
  <T>(...fns: Array<(arg: T) => T>) =>
  (value: T): T =>
    fns.reduce((acc, fn) => fn(acc), value);

// 実用例
const double = (n: number): number => n * 2;
const addOne = (n: number): number => n + 1;
const toString = (n: number): string => n.toString();

const processNumber = pipe(double, addOne, toString);
console.log(processNumber(5)); // "11"

// より実践的な例
type ValidationResult<T> = {
  isValid: boolean;
  value: T;
  errors: string[];
};

const createValidationChain = <T>() => {
  const validators: Array<
    (value: T) => ValidationResult<T>
  > = [];

  return {
    addValidator: (
      validator: (value: T) => ValidationResult<T>
    ) => {
      validators.push(validator);
      return createValidationChain<T>();
    },
    validate: (value: T): ValidationResult<T> => {
      return validators.reduce(
        (result, validator) => {
          if (!result.isValid) return result;
          return validator(result.value);
        },
        { isValid: true, value, errors: [] }
      );
    },
  };
};

モナドとファンクター:TypeScript での表現

Maybe/Option 型の実装

typescript// Maybe/Option型の実装
abstract class Maybe<T> {
  abstract map<U>(fn: (value: T) => U): Maybe<U>;
  abstract flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U>;
  abstract filter(
    predicate: (value: T) => boolean
  ): Maybe<T>;
  abstract getOrElse(defaultValue: T): T;
  abstract isSome(): boolean;
  abstract isNone(): boolean;
}

class Some<T> extends Maybe<T> {
  constructor(private value: T) {
    super();
  }

  map<U>(fn: (value: T) => U): Maybe<U> {
    return new Some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return fn(this.value);
  }

  filter(predicate: (value: T) => boolean): Maybe<T> {
    return predicate(this.value) ? this : new None<T>();
  }

  getOrElse(defaultValue: T): T {
    return this.value;
  }

  isSome(): boolean {
    return true;
  }

  isNone(): boolean {
    return false;
  }
}

class None<T> extends Maybe<T> {
  map<U>(fn: (value: T) => U): Maybe<U> {
    return new None<U>();
  }

  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return new None<U>();
  }

  filter(predicate: (value: T) => boolean): Maybe<T> {
    return this;
  }

  getOrElse(defaultValue: T): T {
    return defaultValue;
  }

  isSome(): boolean {
    return false;
  }

  isNone(): boolean {
    return true;
  }
}

// ヘルパー関数
const some = <T>(value: T): Maybe<T> => new Some(value);
const none = <T>(): Maybe<T> => new None<T>();

// 使用例
const safeParseInt = (str: string): Maybe<number> => {
  const parsed = parseInt(str, 10);
  return isNaN(parsed) ? none<number>() : some(parsed);
};

const result = safeParseInt('42')
  .map((n) => n * 2)
  .filter((n) => n > 50)
  .getOrElse(0);

console.log(result); // 84

Either 型によるエラーハンドリング

typescript// Either型の実装
abstract class Either<L, R> {
  abstract map<U>(fn: (value: R) => U): Either<L, U>;
  abstract flatMap<U>(
    fn: (value: R) => Either<L, U>
  ): Either<L, U>;
  abstract mapLeft<U>(fn: (error: L) => U): Either<U, R>;
  abstract fold<U>(
    leftFn: (error: L) => U,
    rightFn: (value: R) => U
  ): U;
  abstract isLeft(): boolean;
  abstract isRight(): boolean;
}

class Left<L, R> extends Either<L, R> {
  constructor(private error: L) {
    super();
  }

  map<U>(fn: (value: R) => U): Either<L, U> {
    return new Left<L, U>(this.error);
  }

  flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
    return new Left<L, U>(this.error);
  }

  mapLeft<U>(fn: (error: L) => U): Either<U, R> {
    return new Left<U, R>(fn(this.error));
  }

  fold<U>(
    leftFn: (error: L) => U,
    rightFn: (value: R) => U
  ): U {
    return leftFn(this.error);
  }

  isLeft(): boolean {
    return true;
  }

  isRight(): boolean {
    return false;
  }
}

class Right<L, R> extends Either<L, R> {
  constructor(private value: R) {
    super();
  }

  map<U>(fn: (value: R) => U): Either<L, U> {
    return new Right<L, U>(fn(this.value));
  }

  flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
    return fn(this.value);
  }

  mapLeft<U>(fn: (error: L) => U): Either<U, R> {
    return new Right<U, R>(this.value);
  }

  fold<U>(
    leftFn: (error: L) => U,
    rightFn: (value: R) => U
  ): U {
    return rightFn(this.value);
  }

  isLeft(): boolean {
    return false;
  }

  isRight(): boolean {
    return false;
  }
}

// ヘルパー関数
const left = <L, R>(error: L): Either<L, R> =>
  new Left(error);
const right = <L, R>(value: R): Either<L, R> =>
  new Right(value);

// 実用例
type ValidationError = string;

const validateAge = (
  age: number
): Either<ValidationError, number> =>
  age >= 0 && age <= 150
    ? right(age)
    : left('Age must be between 0 and 150');

const validateEmail = (
  email: string
): Either<ValidationError, string> =>
  email.includes('@')
    ? right(email)
    : left('Invalid email format');

const createUser = (
  age: number,
  email: string
): Either<ValidationError, User> =>
  validateAge(age)
    .flatMap(() => validateEmail(email))
    .map(() => ({ age, email, id: Math.random() } as User));

IO モナドと副作用の管理

typescript// IOモナドの実装
class IO<T> {
  constructor(private computation: () => T) {}

  static of<T>(value: T): IO<T> {
    return new IO(() => value);
  }

  map<U>(fn: (value: T) => U): IO<U> {
    return new IO(() => fn(this.computation()));
  }

  flatMap<U>(fn: (value: T) => IO<U>): IO<U> {
    return new IO(() => fn(this.computation()).run());
  }

  run(): T {
    return this.computation();
  }
}

// 副作用のある操作をIOでラップ
const readFile = (filename: string): IO<string> =>
  new IO(() => {
    // 実際のファイル読み込み処理
    console.log(`Reading file: ${filename}`);
    return `Content of ${filename}`;
  });

const writeFile = (
  filename: string,
  content: string
): IO<void> =>
  new IO(() => {
    console.log(`Writing to file: ${filename}`);
    // 実際のファイル書き込み処理
  });

// 副作用の合成
const processFile = (
  inputFile: string,
  outputFile: string
): IO<void> =>
  readFile(inputFile)
    .map((content) => content.toUpperCase())
    .flatMap((processedContent) =>
      writeFile(outputFile, processedContent)
    );

// 実行は最後に行う
processFile('input.txt', 'output.txt').run();

関数型プログラミングパターンの実装

パイプライン処理

typescript// パイプライン処理の実装
class Pipeline<T> {
  constructor(private value: T) {}

  static of<T>(value: T): Pipeline<T> {
    return new Pipeline(value);
  }

  pipe<U>(fn: (value: T) => U): Pipeline<U> {
    return new Pipeline(fn(this.value));
  }

  pipeAsync<U>(
    fn: (value: T) => Promise<U>
  ): Promise<Pipeline<U>> {
    return Promise.resolve(fn(this.value)).then(
      (result) => new Pipeline(result)
    );
  }

  get(): T {
    return this.value;
  }
}

// データ変換パイプライン
const processUserData = (rawData: string) =>
  Pipeline.of(rawData)
    .pipe((data) => JSON.parse(data))
    .pipe((obj) => ({ ...obj, processed: true }))
    .pipe((user) => ({
      ...user,
      displayName: `${user.firstName} ${user.lastName}`,
    }))
    .get();

// 非同期パイプライン
const fetchAndProcessUser = async (userId: string) => {
  return Pipeline.of(userId)
    .pipeAsync((id) => fetch(`/api/users/${id}`))
    .then((pipeline) =>
      pipeline.pipeAsync((response) => response.json())
    )
    .then((pipeline) =>
      pipeline.pipe((user) => ({
        ...user,
        fetchedAt: new Date(),
      }))
    )
    .then((pipeline) => pipeline.get());
};

レンズ(Lens)パターン

typescript// レンズの実装
type Lens<S, A> = {
  get: (source: S) => A;
  set: (value: A) => (source: S) => S;
};

const lens = <S, A>(
  get: (source: S) => A,
  set: (value: A) => (source: S) => S
): Lens<S, A> => ({ get, set });

// ヘルパー関数
const view =
  <S, A>(lens: Lens<S, A>) =>
  (source: S): A =>
    lens.get(source);

const over =
  <S, A>(lens: Lens<S, A>) =>
  (fn: (value: A) => A) =>
  (source: S): S =>
    lens.set(fn(lens.get(source)))(source);

const set =
  <S, A>(lens: Lens<S, A>) =>
  (value: A) =>
  (source: S): S =>
    lens.set(value)(source);

// レンズの合成
const compose = <S, A, B>(
  outer: Lens<S, A>,
  inner: Lens<A, B>
): Lens<S, B> =>
  lens(
    (source) => inner.get(outer.get(source)),
    (value) => (source) =>
      outer.set(inner.set(value)(outer.get(source)))(source)
  );

// 使用例
type Address = {
  street: string;
  city: string;
  zipCode: string;
};

type Person = {
  name: string;
  age: number;
  address: Address;
};

// レンズの定義
const nameLens: Lens<Person, string> = lens(
  (person) => person.name,
  (name) => (person) => ({ ...person, name })
);

const addressLens: Lens<Person, Address> = lens(
  (person) => person.address,
  (address) => (person) => ({ ...person, address })
);

const streetLens: Lens<Address, string> = lens(
  (address) => address.street,
  (street) => (address) => ({ ...address, street })
);

// レンズの合成
const personStreetLens = compose(addressLens, streetLens);

// 使用
const person: Person = {
  name: 'John',
  age: 30,
  address: {
    street: '123 Main St',
    city: 'Tokyo',
    zipCode: '100-0001',
  },
};

const updatedPerson = set(personStreetLens)('456 Oak Ave')(
  person
);

状態管理の関数型アプローチ

typescript// 関数型状態管理
type State<T> = {
  readonly value: T;
  readonly history: readonly T[];
};

type StateUpdate<T> = (current: T) => T;

class StateManager<T> {
  constructor(private state: State<T>) {}

  static create<T>(initialValue: T): StateManager<T> {
    return new StateManager({
      value: initialValue,
      history: [initialValue],
    });
  }

  update(updateFn: StateUpdate<T>): StateManager<T> {
    const newValue = updateFn(this.state.value);
    return new StateManager({
      value: newValue,
      history: [...this.state.history, newValue],
    });
  }

  getValue(): T {
    return this.state.value;
  }

  getHistory(): readonly T[] {
    return this.state.history;
  }

  undo(): StateManager<T> {
    if (this.state.history.length <= 1) return this;

    const newHistory = this.state.history.slice(0, -1);
    return new StateManager({
      value: newHistory[newHistory.length - 1],
      history: newHistory,
    });
  }
}

// 使用例
type CounterState = {
  count: number;
  lastOperation: string;
};

const counterManager = StateManager.create<CounterState>({
  count: 0,
  lastOperation: 'init',
});

const increment = (state: CounterState): CounterState => ({
  count: state.count + 1,
  lastOperation: 'increment',
});

const decrement = (state: CounterState): CounterState => ({
  count: state.count - 1,
  lastOperation: 'decrement',
});

const finalState = counterManager
  .update(increment)
  .update(increment)
  .update(decrement)
  .getValue();

実際のプロジェクトでの活用例

typescript// APIクライアントの関数型実装
type ApiResult<T> = Either<string, T>;

const apiClient = {
  get: <T>(url: string): Promise<ApiResult<T>> =>
    fetch(url)
      .then((response) =>
        response.ok
          ? response
              .json()
              .then((data) => right<string, T>(data))
          : left<string, T>(
              `HTTP ${response.status}: ${response.statusText}`
            )
      )
      .catch((error) => left<string, T>(error.message)),

  post: <T>(
    url: string,
    data: unknown
  ): Promise<ApiResult<T>> =>
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })
      .then((response) =>
        response.ok
          ? response
              .json()
              .then((data) => right<string, T>(data))
          : left<string, T>(
              `HTTP ${response.status}: ${response.statusText}`
            )
      )
      .catch((error) => left<string, T>(error.message)),
};

// フォームバリデーションの関数型実装
type ValidationRule<T> = (value: T) => Either<string, T>;

const required: ValidationRule<string> = (value) =>
  value.trim().length > 0
    ? right(value)
    : left('This field is required');

const minLength =
  (min: number): ValidationRule<string> =>
  (value) =>
    value.length >= min
      ? right(value)
      : left(`Minimum length is ${min}`);

const email: ValidationRule<string> = (value) =>
  value.includes('@')
    ? right(value)
    : left('Invalid email format');

const validateField = <T>(
  value: T,
  ...rules: ValidationRule<T>[]
): Either<string[], T> => {
  const errors: string[] = [];

  for (const rule of rules) {
    const result = rule(value);
    if (result.isLeft()) {
      result.fold(
        (error) => errors.push(error),
        () => {}
      );
    }
  }

  return errors.length > 0 ? left(errors) : right(value);
};

// 使用例
const validateUserForm = (formData: {
  email: string;
  password: string;
}) => {
  const emailResult = validateField(
    formData.email,
    required,
    email
  );
  const passwordResult = validateField(
    formData.password,
    required,
    minLength(8)
  );

  if (emailResult.isLeft() || passwordResult.isLeft()) {
    const allErrors = [
      ...(emailResult.isLeft()
        ? emailResult.fold(
            (errors) => errors,
            () => []
          )
        : []),
      ...(passwordResult.isLeft()
        ? passwordResult.fold(
            (errors) => errors,
            () => []
          )
        : []),
    ];
    return left(allErrors);
  }

  return right({
    email: formData.email,
    password: formData.password,
  });
};

// データ処理パイプライン
const processOrderData = async (rawOrders: unknown[]) => {
  return Pipeline.of(rawOrders)
    .pipe((orders) =>
      orders.filter((order) => order != null)
    )
    .pipe((orders) =>
      orders.map((order) => ({
        ...(order as any),
        total: calculateOrderTotal(order as any),
      }))
    )
    .pipe((orders) =>
      orders.sort(
        (a, b) =>
          new Date(b.createdAt).getTime() -
          new Date(a.createdAt).getTime()
      )
    )
    .get();
};

const calculateOrderTotal = (order: {
  items: Array<{ price: number; quantity: number }>;
}): number =>
  order.items.reduce(
    (total, item) => total + item.price * item.quantity,
    0
  );

まとめ:関数型思考で書く堅牢な TypeScript コード

関数型プログラミングは、TypeScript の型システムと組み合わせることで、以下のような利点をもたらします:

  1. 予測可能性: 純粋関数により、コードの動作が予測しやすくなります
  2. テスト容易性: 副作用のない関数は、単体テストが書きやすくなります
  3. 保守性: 不変性により、意図しない状態変更を防げます
  4. 合成可能性: 小さな関数を組み合わせて、複雑な処理を構築できます
  5. 型安全性: TypeScript の型システムにより、関数型の概念を安全に表現できます

関数型プログラミングの導入は段階的に行うことができます。まずは純粋関数と不変性から始めて、徐々にモナドや高度なパターンを取り入れていくことをお勧めします。

関連リンク