Practical Data Transformations について
TypeScript 開発者向けに、日常的なデータ変換を命令型と関数型の両方で解説します。配列、オブジェクト、正規化、集計、null 安全なアクセスを扱います。
主な機能
- 01配列の map、filter、reduce
- 02オブジェクトの選択、結合、不変更新
- 03API レスポンスをアプリ状態に正規化
- 04キーによるグループ化と集計
- 05ネストした値への安全なアクセス
- 06命令型と関数型の比較
活用シーン
- 01条件に合うユーザーの抽出と並べ替え
- 02API データを UI 向けに整形
- 03顧客やステータス別の注文集計
- 04設定の結合とネスト状態の更新
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill practical-data-transformationsインストール後、Claude Code で/practical-data-transformationsを入力して読み込まれたか確認します。
詳細
実用的なデータ変換
このスキルでは、配列の操作、オブジェクトの再構成、API レスポンスの正規化、データのグループ化、ネストされた値への安全なアクセスなど、日常的に行うデータ変換を扱います。各セクションでは、まず命令型の方法を示し、次に関数型の方法を示します。それぞれの方法が力を発揮する場面についても、率直に評価します。
目次
1. 配列操作
配列操作は、データ変換の基本です。冗長なループを、表現力があり連結可能な操作に置き換えてみましょう。
Map:すべての要素を変換する
タスク:価格の配列をセントからドルに変換します。
命令型の方法
const pricesInCents = [999, 1499, 2999, 4999];
function convertToDollars(prices: number[]): number[] {
const result: number[] = [];
for (let i = 0; i < prices.length; i++) {
result.push(prices[i] / 100);
}
return result;
}
const dollars = convertToDollars(pricesInCents);
// [9.99, 14.99, 29.99, 49.99]
関数型の方法
const pricesInCents = [999, 1499, 2999, 4999];
const toDollars = (cents: number): number => cents / 100;
const dollars = pricesInCents.map(toDollars);
// [9.99, 14.99, 29.99, 49.99]
ここで関数型のほうが優れている理由:意図がすぐにわかります。map は「各要素を変換する」という意味です。変換ロジック(toDollars)には名前が付いており、再利用できます。インデックスの管理も、手動で配列を作る必要もありません。
Filter:条件に一致するものを残す
タスク:リストからアクティブなユーザーをすべて取得します。
命令型の方法
interface User {
id: string;
name: string;
isActive: boolean;
}
function getActiveUsers(users: User[]): User[] {
const result: User[] = [];
for (const user of users) {
if (user.isActive) {
result.push(user);
}
}
return result;
}
関数型の方法
const isActive = (user: User): boolean => user.isActive;
const activeUsers = users.filter(isActive);
// Or inline for simple predicates
const activeUsers = users.filter(user => user.isActive);
ここで関数型のほうが優れている理由:述語(isActive)を反復処理のロジックから切り離せます。述語を個別に再利用、テスト、合成できます。
Reduce:新しい値に累積する
タスク:カート内のアイテムの合計金額を計算します。
命令型の方法
interface CartItem {
name: string;
price: number;
quantity: number;
}
function calculateTotal(items: CartItem[]): number {
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
}
return total;
}
関数型の方法
const calculateTotal = (items: CartItem[]): number =>
items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
// Or break out the line total calculation
const lineTotal = (item: CartItem): number => item.price * item.quantity;
const calculateTotal = (items: CartItem[]): number =>
items.map(lineTotal).reduce((a, b) => a + b, 0);
率直な評価:単純な合計であれば、命令型のループのほうが実際にはかなり読みやすいものです。関数型の方法が力を発揮するのは、累積処理をほかの変換と合成する必要がある場合や、削減ロジックが複雑で、名前を付けることで理解しやすくなる場合です。
連結:操作を組み合わせる
タスク:アクティブなプレミアムユーザーの名前をすべて取得し、アルファベット順に並べます。
命令型の方法
interface User {
id: string;
name: string;
isActive: boolean;
tier: 'free' | 'premium';
}
function getActivePremiumNames(users: User[]): string[] {
const result: string[] = [];
for (const user of users) {
if (user.isActive && user.tier === 'premium') {
result.push(user.name);
}
}
result.sort((a, b) => a.localeCompare(b));
return result;
}
関数型の方法
const getActivePremiumNames = (users: User[]): string[] =>
users
.filter(user => user.isActive)
.filter(user => user.tier === 'premium')
.map(user => user.name)
.sort((a, b) => a.localeCompare(b));
// Or with named predicates for reuse
const isActive = (user: User): boolean => user.isActive;
const isPremium = (user: User): boolean => user.tier === 'premium';
const getName = (user: User): string => user.name;
const alphabetically = (a: string, b: string): number => a.localeCompare(b);
const getActivePremiumNames = (users: User[]): string[] =>
users
.filter(isActive)
.filter(isPremium)
.map(getName)
.sort(alphabetically);
ここで関数型のほうが優れている理由:連結された各ステップが単一の役割を持っています。「アクティブなユーザーを絞り込み、プレミアムユーザーを絞り込み、名前を取得し、並べ替える」という一連の手順として変換を読み取れます。ステップの追加や削除も簡単です。
fp-ts の Array モジュールを使う
fp-ts には、合成をより適切にサポートする配列ユーティリティが用意されています。
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
// Safe head (first element)
const first = pipe(
[1, 2, 3],
A.head
); // Some(1)
const firstOfEmpty = pipe(
[] as number[],
A.head
); // None
// Safe lookup by index
const third = pipe(
['a', 'b', 'c', 'd'],
A.lookup(2)
); // Some('c')
// Find with predicate
const found = pipe(
users,
A.findFirst(user => user.id === 'abc123')
); // Option<User>
// Partition into two groups
const [inactive, active] = pipe(
users,
A.partition(user => user.isActive)
);
// Take first N elements
const topThree = pipe(
sortedScores,
A.takeLeft(3)
);
// Unique values
const uniqueTags = pipe(
allTags,
A.uniq({ equals: (a, b) => a === b })
);
2. オブジェクト変換
オブジェクトでは、フィールドの選択、機密データの除外、設定のマージ、ネストされた値の更新など、形を整える作業が頻繁に発生します。
Pick:特定のフィールドを選択する
タスク:ユーザーオブジェクトから公開フィールドのみを取り出します。
命令型の方法
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
internalNotes: string;
}
function getPublicUser(user: User): { id: string; name: string; email: string } {
return {
id: user.id,
name: user.name,
email: user.email,
};
}
関数型の方法
// Generic pick utility
const pick = <T extends object, K extends keyof T>(
keys: K[]
) => (obj: T): Pick<T, K> =>
keys.reduce(
(result, key) => {
result[key] = obj[key];
return result;
},
{} as Pick<T, K>
);
const getPublicUser = pick<User, 'id' | 'name' | 'email'>(['id', 'name', 'email']);
const publicUser = getPublicUser(user);
ここで関数型のほうが優れている理由:pick ユーティリティはコードベース全体で再利用できます。型安全性により、実在するキーのみを選択できます。
Omit:特定のフィールドを削除する
タスク:ログに記録する前に機密フィールドを削除します。
命令型の方法
function sanitizeForLogging(user: User): Omit<User, 'passwordHash' | 'internalNotes'> {
const { passwordHash, internalNotes, ...safe } = user;
return safe;
}
関数型の方法
// Generic omit utility
const omit = <T extends object, K extends keyof T>(
keys: K[]
) => (obj: T): Omit<T, K> => {
const result = { ...obj };
for (const key of keys) {
delete result[key];
}
return result as Omit<T, K>;
};
const sanitizeForLogging = omit<User, 'passwordHash' | 'internalNotes'>([
'passwordHash',
'internalNotes',
]);
率直な評価:一度きりの削除であれば、分割代入(命令型の方法)で十分に問題なく、とても読みやすい方法です。関数型の omit ユーティリティが効果を発揮するのは、このような変換が多数ある場合や、変換を合成する必要がある場合です。
Merge:オブジェクトを組み合わせる
タスク:ユーザー設定をデフォルト値とマージします。
命令型の方法
interface Settings {
theme: 'light' | 'dark';
fontSize: number;
notifications: boolean;
language: string;
}
function mergeSettings(
defaults: Settings,
userSettings: Partial<Settings>
): Settings {
return {
theme: userSettings.theme !== undefined ? userSettings.theme : defaults.theme,
fontSize: userSettings.fontSize !== undefined ? userSettings.fontSize : defaults.fontSize,
notifications: userSettings.notifications !== undefined
? userSettings.notifications
: defaults.notifications,
language: userSettings.language !== undefined ? userSettings.language : defaults.language,
};
}
関数型の方法
const mergeSettings = (
defaults: Settings,
userSettings: Partial<Settings>
): Settings => ({
...defaults,
...userSettings,
});
// Usage
const defaults: Settings = {
theme: 'light',
fontSize: 14,
notifications: true,
language: 'en',
};
const userPrefs: Partial<Settings> = {
theme: 'dark',
fontSize: 16,
};
const finalSettings = mergeSettings(defaults, userPrefs);
// { theme: 'dark', fontSize: 16, notifications: true, language: 'en' }
ここで関数型のほうが優れている理由:スプレッド構文は簡潔で、任意の数のキーを扱えます。後に指定したスプレッドが前のものを上書きするため、自然に「デフォルト値に上書き値を適用する」動作になります。
Deep Merge:ネストされたオブジェクトの結合
タスク:ネストされた設定オブジェクトをマージします。
命令型の方法
interface Config {
api: {
baseUrl: string;
timeout: number;
retries: number;
};
ui: {
theme: string;
animations: boolean;
};
}
function deepMerge(
target: Config,
source: Partial<Config>
): Config {
const result = { ...target };
if (source.api) {
result.api = { ...target.api, ...source.api };
}
if (source.ui) {
result.ui = { ...target.ui, ...source.ui };
}
return result;
}
関数型の方法
// Generic deep merge for one level of nesting
const deepMerge = <T extends Record<string, object>>(
target: T,
source: { [K in keyof T]?: Partial<T[K]> }
): T => {
const result = { ...target };
for (const key of Object.keys(source) as Array<keyof T>) {
if (source[key] !== undefined) {
result[key] = { ...target[key], ...source[key] };
}
}
return result;
};
// Usage
const defaultConfig: Config = {
api: { baseUrl: 'https://api.example.com', timeout: 5000, retries: 3 },
ui: { theme: 'light', animations: true },
};
const customConfig = deepMerge(defaultConfig, {
api: { timeout: 10000 },
ui: { theme: 'dark' },
});
// api.baseUrl preserved, api.timeout overridden
// ui.theme overridden, ui.animations preserved
不変な更新:ネストされた値を変更する
課題:変更を加えずに、深くネストされた値を更新します。
命令型(変更を伴う)アプローチ
interface State {
user: {
profile: {
settings: {
theme: string;
};
};
};
}
function updateTheme(state: State, newTheme: string): void {
state.user.profile.settings.theme = newTheme; // Mutation!
}
関数型(不変)アプローチ
// Manual spread nesting
const updateTheme = (state: State, newTheme: string): State => ({
...state,
user: {
...state.user,
profile: {
...state.user.profile,
settings: {
...state.user.profile.settings,
theme: newTheme,
},
},
},
});
// With a lens-like helper
const updatePath = <T, V>(
obj: T,
path: string[],
value: V
): T => {
if (path.length === 0) return value as unknown as T;
const [head, ...rest] = path;
return {
...obj,
[head]: updatePath((obj as Record<string, unknown>)[head], rest, value),
} as T;
};
const newState = updatePath(state, ['user', 'profile', 'settings', 'theme'], 'dark');
率直な評価:スプレッド構文をネストする方法は冗長ですが、明示的です。深くネストされた値を更新する場合は、immer や fp-ts のレンズのようなライブラリを検討してください。関数型アプローチの冗長さは、不変性を保つための代償です。
3. データの正規化
API レスポンスがアプリに必要な形状と一致することはほとんどありません。正規化では、ネストされた非正規化データを、フラットでインデックス化された構造に変換します。
API レスポンスからアプリの状態へ
課題:ネストされた API レスポンスを、正規化された状態に変換します。
API レスポンス(取得するデータ)
interface ApiResponse {
orders: Array<{
id: string;
customerId: string;
customerName: string;
customerEmail: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
price: number;
}>;
total: number;
status: string;
}>;
}
アプリの状態(必要なデータ)
interface NormalizedState {
orders: {
byId: Record<string, Order>;
allIds: string[];
};
customers: {
byId: Record<string, Customer>;
allIds: string[];
};
products: {
byId: Record<string, Product>;
allIds: string[];
};
}
interface Order {
id: string;
customerId: string;
itemIds: string[];
total: number;
status: string;
}
interface Customer {
id: string;
name: string;
email: string;
}
interface Product {
id: string;
name: string;
price: number;
}
命令型アプローチ
function normalizeApiResponse(response: ApiResponse): NormalizedState {
const state: NormalizedState = {
orders: { byId: {}, allIds: [] },
customers: { byId: {}, allIds: [] },
products: { byId: {}, allIds: [] },
};
for (const order of response.orders) {
// Extract customer
if (!state.customers.byId[order.customerId]) {
state.customers.byId[order.customerId] = {
id: order.customerId,
name: order.customerName,
email: order.customerEmail,
};
state.customers.allIds.push(order.customerId);
}
// Extract products and build item IDs
const itemIds: string[] = [];
for (const item of order.items) {
if (!state.products.byId[item.productId]) {
state.products.byId[item.productId] = {
id: item.productId,
name: item.productName,
price: item.price,
};
state.products.allIds.push(item.productId);
}
itemIds.push(item.productId);
}
// Add normalized order
state.orders.byId[order.id] = {
id: order.id,
customerId: order.customerId,
itemIds,
total: order.total,
status: order.status,
};
state.orders.allIds.push(order.id);
}
return state;
}
関数型アプローチ
import { pipe } from 'fp-ts/function';
import * as A from 'fp-ts/Array';
import * as R from 'fp-ts/Record';
// Helper to create normalized collection
interface NormalizedCollection<T extends { id: string }> {
byId: Record<string, T>;
allIds: string[];
}
const createNormalizedCollection = <T extends { id: string }>(
items: T[]
): NormalizedCollection<T> => ({
byId: pipe(
items,
A.reduce({} as Record<string, T>, (acc, item) => ({
...acc,
[item.id]: item,
}))
),
allIds: items.map(item => item.id),
});
// Extract entities
const extractCustomers = (orders: ApiResponse['orders']): Customer[] =>
pipe(
orders,
A.map(order => ({
id: order.customerId,
name: order.customerName,
email: order.customerEmail,
})),
A.uniq({ equals: (a, b) => a.id === b.id })
);
const extractProducts = (orders: ApiResponse['orders']): Product[] =>
pipe(
orders,
A.flatMap(order => order.items),
A.map(item => ({
id: item.productId,
name: item.productName,
price: item.price,
})),
A.uniq({ equals: (a, b) => a.id === b.id })
);
const extractOrders = (orders: ApiResponse['orders']): Order[] =>
orders.map(order => ({
id: order.id,
customerId: order.customerId,
itemIds: order.items.map(item => item.productId),
total: order.total,
status: order.status,
}));
// Compose into final normalization
const normalizeApiResponse = (response: ApiResponse): NormalizedState => ({
orders: createNormalizedCollection(extractOrders(response.orders)),
customers: createNormalizedCollection(extractCustomers(response.orders)),
products: createNormalizedCollection(extractProducts(response.orders)),
});
ここで関数型アプローチが優れている理由:各データの抽出は独立しており、テスト可能です。createNormalizedCollection ヘルパーは再利用できます。エンティティ型を追加するには、新しい抽出関数を1つ追加するだけです。
API レスポンスを UI 用データに変換する
課題:API データをコンポーネントが必要とする形に変換します。
// API gives you this
interface ApiUser {
user_id: string;
first_name: string;
last_name: string;
email_address: string;
created_at: string; // ISO string
avatar_url: string | null;
}
// Components need this
interface DisplayUser {
id: string;
fullName: string;
email: string;
memberSince: string; // "Jan 2024"
avatarUrl: string; // With fallback
}
関数型アプローチ
const formatDate = (isoString: string): string => {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
};
const DEFAULT_AVATAR = 'https://example.com/default-avatar.png';
const toDisplayUser = (apiUser: ApiUser): DisplayUser => ({
id: apiUser.user_id,
fullName: `${apiUser.first_name} ${apiUser.last_name}`,
email: apiUser.email_address,
memberSince: formatDate(apiUser.created_at),
avatarUrl: apiUser.avatar_url ?? DEFAULT_AVATAR,
});
// Transform array of users
const toDisplayUsers = (apiUsers: ApiUser[]): DisplayUser[] =>
apiUsers.map(toDisplayUser);
4. グループ化と集計
データのグループ化と集計は、レポート、ダッシュボード、分析に不可欠です。
GroupBy:キーで整理する
課題:注文を顧客ごとにグループ化します。
命令型アプローチ
interface Order {
id: string;
customerId: string;
total: number;
date: string;
}
function groupByCustomer(orders: Order[]): Record<string, Order[]> {
const result: Record<string, Order[]> = {};
for (const order of orders) {
if (!result[order.customerId]) {
result[order.customerId] = [];
}
result[order.customerId].push(order);
}
return result;
}
関数型アプローチ
// Generic groupBy utility
const groupBy = <T, K extends string | number>(
getKey: (item: T) => K
) => (items: T[]): Record<K, T[]> =>
items.reduce(
(groups, item) => {
const key = getKey(item);
return {
...groups,
[key]: [...(groups[key] || []), item],
};
},
{} as Record<K, T[]>
);
// Usage
const groupByCustomer = groupBy<Order, string>(order => order.customerId);
const ordersByCustomer = groupByCustomer(orders);
// Or inline
const ordersByStatus = groupBy((order: Order) => order.status)(orders);
fp-ts の NonEmptyArray.groupBy を使用する場合:
import * as NEA from 'fp-ts/NonEmptyArray';
import { pipe } from 'fp-ts/function';
// NEA.groupBy guarantees non-empty arrays in result
const ordersByCustomer = pipe(
orders as NEA.NonEmptyArray<Order>, // Must be non-empty
NEA.groupBy(order => order.customerId)
); // Record<string, NonEmptyArray<Order>>
CountBy:出現回数を数える
課題:ステータスごとに注文数を数えます。
命令型アプローチ
function countByStatus(orders: Order[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const order of orders) {
counts[order.status] = (counts[order.status] || 0) + 1;
}
return counts;
}
関数型アプローチ
// Generic countBy utility
const countBy = <T, K extends string>(
getKey: (item: T) => K
) => (items: T[]): Record<K, number> =>
items.reduce(
(counts, item) => {
const key = getKey(item);
return {
...counts,
[key]: (counts[key] || 0) + 1,
};
},
{} as Record<K, number>
);
// Usage
const orderCountByStatus = countBy((order: Order) => order.status)(orders);
// { pending: 5, shipped: 12, delivered: 8 }
SumBy:数値を集計する
課題:商品カテゴリごとの総売上を計算します。
命令型アプローチ
interface Sale {
productId: string;
category: string;
amount: number;
}
function sumByCategory(sales: Sale[]): Record<string, number> {
const totals: Record<string, number> = {};
for (const sale of sales) {
totals[sale.category] = (totals[sale.category] || 0) + sale.amount;
}
return totals;
}
関数型アプローチ
// Generic sumBy utility
const sumBy = <T, K extends string>(
getKey: (item: T) => K,
getValue: (item: T) => number
) => (items: T[]): Record<K, number> =>
items.reduce(
(totals, item) => {
const key = getKey(item);
return {
...totals,
[key]: (totals[key] || 0) + getValue(item),
};
},
{} as Record<K, number>
);
// Usage
const revenueByCategory = sumBy(
(sale: Sale) => sale.category,
(sale: Sale) => sale.amount
)(sales);
// { electronics: 15000, clothing: 8500, books: 3200 }
複雑な集計の例
課題:数量と単価を含む明細項目から合計を計算します。
interface LineItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}
interface Invoice {
id: string;
lineItems: LineItem[];
taxRate: number;
}
関数型アプローチ
const lineTotal = (item: LineItem): number =>
item.quantity * item.unitPrice;
const subtotal = (items: LineItem[]): number =>
items.reduce((sum, item) => sum + lineTotal(item), 0);
const calculateTax = (amount: number, rate: number): number =>
amount * rate;
const calculateInvoiceTotal = (invoice: Invoice): {
subtotal: number;
tax: number;
total: number;
} => {
const sub = subtotal(invoice.lineItems);
const tax = calculateTax(sub, invoice.taxRate);
return {
subtotal: sub,
tax,
total: sub + tax,
};
};
// With fp-ts pipe for clarity
import { pipe } from 'fp-ts/function';
const calculateInvoiceTotal = (invoice: Invoice) => {
const sub = pipe(
invoice.lineItems,
A.map(lineTotal),
A.reduce(0, (a, b) => a + b)
);
return {
subtotal: sub,
tax: sub * invoice.taxRate,
total: sub * (1 + invoice.taxRate),
};
};
5. null 安全なアクセス
if (x && x.y && x.y.z) と書くのはやめましょう。実行時エラーを起こさずに、ネストされた構造を安全にたどれます。
問題
interface Config {
database?: {
connection?: {
host?: string;
port?: number;
};
pool?: {
max?: number;
};
};
features?: {
experimental?: {
enabled?: boolean;
};
};
}
命令型(冗長な)アプローチ
function getDatabaseHost(config: Config): string {
if (
config.database &&
config.database.connection &&
config.database.connection.host
) {
return config.database.connection.host;
}
return 'localhost';
}
オプショナルチェーン(最新の TypeScript)
const getDatabaseHost = (config: Config): string =>
config.database?.connection?.host ?? 'localhost';
率直な評価:単純なアクセスパターンには、オプショナルチェーン(?.)が最適です。言語に組み込まれており、非常に読みやすくなっています。欠損する可能性のある値に対して操作を合成する必要がある場合は、fp-ts の Option を使用してください。
Option を使うべき場合
次の場合は fp-ts の Option を使用してください。
- 欠損する可能性のある値に対して複数の操作を連鎖させる必要がある
- 「欠損」と、それ以外の偽値を区別したい
- 変換のパイプラインを構築している
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
// Safe property access that returns Option
const prop = <T, K extends keyof T>(key: K) =>
(obj: T | null | undefined): O.Option<T[K]> =>
obj != null && key in obj
? O.some(obj[key] as T[K])
: O.none;
// Chain accesses with flatMap
const getDatabaseHost = (config: Config): O.Option<string> =>
pipe(
O.some(config),
O.flatMap(prop('database')),
O.flatMap(prop('connection')),
O.flatMap(prop('host'))
);
// Extract with default
const host = pipe(
getDatabaseHost(config),
O.getOrElse(() => 'localhost')
);
配列への安全なアクセス
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
// Imperative: throws if array is empty
const first = items[0]; // Could be undefined!
// Safe: returns Option
const first = A.head(items); // Option<Item>
// Get first item's name, or default
const firstName = pipe(
items,
A.head,
O.map(item => item.name),
O.getOrElse(() => 'No items')
);
// Safe lookup by index
const third = pipe(
items,
A.lookup(2),
O.map(item => item.name),
O.getOrElse(() => 'Not found')
);
Record/辞書への安全なアクセス
import * as R from 'fp-ts/Record';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
const users: Record<string, User> = {
'user-1': { name: 'Alice', email: '[email protected]' },
'user-2': { name: 'Bob', email: '[email protected]' },
};
// Imperative: could be undefined
const user = users['user-3']; // User | undefined
// Safe: returns Option
const user = R.lookup('user-3')(users); // Option<User>
// Get user email or default
const email = pipe(
users,
R.lookup('user-3'),
O.map(u => u.email),
O.getOrElse(() => '[email protected]')
);
複数のオプショナルな値を組み合わせる
課題:姓と名の両方が必要な、ユーザーの表示名を取得します。
interface Profile {
firstName?: string;
lastName?: string;
nickname?: string;
}
// Imperative
function getDisplayName(profile: Profile): string {
if (profile.firstName && profile.lastName) {
return `${profile.firstName} ${profile.lastName}`;
}
if (profile.nickname) {
return profile.nickname;
}
return 'Anonymous';
}
// Functional with Option
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
const getDisplayName = (profile: Profile): string =>
pipe(
// Try full name first
O.Do,
O.bind('first', () => O.fromNullable(profile.firstName)),
O.bind('last', () => O.fromNullable(profile.lastName)),
O.map(({ first, last }) => `${first} ${last}`),
// Fall back to nickname
O.alt(() => O.fromNullable(profile.nickname)),
// Finally, default to Anonymous
O.getOrElse(() => 'Anonymous')
);
6. 実例
例 1:API レスポンスを UI 用データに変換する
// API response
interface ApiOrder {
order_id: string;
customer: {
id: string;
full_name: string;
};
line_items: Array<{
product_id: string;
product_name: string;
qty: number;
unit_price: number;
}>;
order_date: string;
status: 'pending' | 'processing' | 'shipped' | 'delivered';
}
// What the UI needs
interface OrderSummary {
id: string;
customerName: string;
itemCount: number;
total: number;
formattedTotal: string;
date: string;
statusLabel: string;
statusColor: string;
}
// Transformation
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Pending', color: 'yellow' },
processing: { label: 'Processing', color: 'blue' },
shipped: { label: 'Shipped', color: 'purple' },
delivered: { label: 'Delivered', color: 'green' },
};
const formatCurrency = (cents: number): string =>
`$${(cents / 100).toFixed(2)}`;
const formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const toOrderSummary = (order: ApiOrder): OrderSummary => {
const total = order.line_items.reduce(
(sum, item) => sum + item.qty * item.unit_price,
0
);
const status = STATUS_CONFIG[order.status] ?? STATUS_CONFIG.pending;
return {
id: order.order_id,
customerName: order.customer.full_name,
itemCount: order.line_items.reduce((sum, item) => sum + item.qty, 0),
total,
formattedTotal: formatCurrency(total),
date: formatDate(order.order_date),
statusLabel: status.label,
statusColor: status.color,
};
};
// Transform all orders
const toOrderSummaries = (orders: ApiOrder[]): OrderSummary[] =>
orders.map(toOrderSummary);
例 2:ユーザー設定をデフォルト値とマージする
interface AppSettings {
theme: {
mode: 'light' | 'dark' | 'system';
primaryColor: string;
fontSize: 'small' | 'medium' | 'large';
};
notifications: {
email: boolean;
push: boolean;
sms: boolean;
frequency: 'immediate' | 'daily' | 'weekly';
};
privacy: {
showProfile: boolean;
showActivity: boolean;
allowAnalytics: boolean;
};
}
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
const DEFAULT_SETTINGS: AppSettings = {
theme: {
mode: 'system',
primaryColor: '#007bff',
fontSize: 'medium',
},
notifications: {
email: true,
push: true,
sms: false,
frequency: 'immediate',
},
privacy: {
showProfile: true,
showActivity: true,
allowAnalytics: true,
},
};
const deepMergeSettings = (
defaults: AppSettings,
user: DeepPartial<AppSettings>
): AppSettings => ({
theme: { ...defaults.theme, ...user.theme },
notifications: { ...defaults.notifications, ...user.notifications },
privacy: { ...defaults.privacy, ...user.privacy },
});
// Usage
const userPreferences: DeepPartial<AppSettings> = {
theme: { mode: 'dark' },
notifications: { sms: true, frequency: 'daily' },
};
const finalSettings = deepMergeSettings(DEFAULT_SETTINGS, userPreferences);
例 3:注文を顧客ごとにグループ化し、合計を計算する
interface Order {
id: string;
customerId: string;
customerName: string;
items: Array<{ name: string; price: number; quantity: number }>;
date: string;
}
interface CustomerOrderSummary {
customerId: string;
customerName: string;
orderCount: number;
totalSpent: number;
orders: Order[];
}
const calculateOrderTotal = (order: Order): number =>
order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const groupOrdersByCustomer = (orders: Order[]): CustomerOrderSummary[] => {
const grouped = groupBy((order: Order) => order.customerId)(orders);
return Object.entries(grouped).map(([customerId, customerOrders]) => ({
customerId,
customerName: customerOrders[0].customerName,
orderCount: customerOrders.length,
totalSpent: customerOrders.reduce(
(sum, order) => sum + calculateOrderTotal(order),
0
),
orders: customerOrders,
}));
};
例 4:深くネストされた設定に安全にアクセスする
interface AppConfig {
services?: {
api?: {
endpoints?: {
users?: string;
orders?: string;
products?: string;
};
auth?: {
type?: 'bearer' | 'basic' | 'oauth';
token?: string;
};
};
database?: {
primary?: {
host?: string;
port?: number;
name?: string;
};
};
};
}
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
// Create a type-safe config accessor
const getConfigValue = <T>(
config: AppConfig,
path: (config: AppConfig) => T | undefined,
defaultValue: T
): T => path(config) ?? defaultValue;
// Usage with optional chaining (simplest)
const apiUsersEndpoint = getConfigValue(
config,
c => c.services?.api?.endpoints?.users,
'/api/users'
);
// For more complex scenarios, use Option
const getEndpoint = (config: AppConfig, name: 'users' | 'orders' | 'products'): string =>
pipe(
O.fromNullable(config.services),
O.flatMap(s => O.fromNullable(s.api)),
O.flatMap(a => O.fromNullable(a.endpoints)),
O.flatMap(e => O.fromNullable(e[name])),
O.getOrElse(() => `/api/${name}`)
);
// Reusable pattern for multiple values
const getDbConfig = (config: AppConfig) => ({
host: config.services?.database?.primary?.host ?? 'localhost',
port: config.services?.database?.primary?.port ?? 5432,
name: config.services?.database?.primary?.name ?? 'app',
});
7. 使い分け
ネイティブメソッドを使う場合:
- 単純な変換:
.map()、.filter()、.reduce()で十分です - 合成が不要:単発の変換を行う場合
- チームの習熟度:全員がネイティブメソッドを知っている場合
- オプショナルチェーンで十分:
obj?.prop?.value ?? defaultで null 安全性の要件を満たせる場合
// Native is fine here
const activeUserNames = users
.filter(u => u.isActive)
.map(u => u.name);
fp-ts を使う場合:
- 失敗する可能性のある操作の連鎖:各ステップで値が返らない可能性がある複数の処理
- 変換の合成:再利用可能な変換パイプラインを構築する場合
- 型安全なエラー処理:コンパイラーに潜在的な失敗を追跡させたい場合
- 複雑なデータパイプライン:明示的な合成が役立つ、多数のステップを含む場合
// fp-ts shines here
const result = pipe(
users,
A.findFirst(u => u.id === userId),
O.flatMap(u => O.fromNullable(u.profile)),
O.flatMap(p => O.fromNullable(p.settings)),
O.map(s => s.theme),
O.getOrElse(() => 'default')
);
カスタムユーティリティを使う場合:
- ドメイン固有の操作:データに対する
groupBy、countBy、sumBy - 繰り返しのパターン:同じ変換を何度も記述している場合
- チームの規約:コードベース全体で一貫したパターンを確立する場合
// Custom utility pays off when used repeatedly
const revenueByRegion = sumBy(
(sale: Sale) => sale.region,
(sale: Sale) => sale.amount
)(sales);
パフォーマンスに関する考慮事項
- チェーン処理では中間配列が作成されます:
arr.filter().map()は配列を 1 つ作成した後、さらにもう 1 つ作成します - 処理頻度の高い箇所では、
reduceを検討します:データを 1 回だけ走査します - 最適化の前に計測します:最適化による可読性の低下は、多くの場合、その価値に見合いません
// If performance matters (and you've measured!)
const result = items.reduce((acc, item) => {
if (item.isActive) {
acc.push(item.name.toUpperCase());
}
return acc;
}, [] as string[]);
// vs the more readable (but 2-pass) version
const result = items
.filter(item => item.isActive)
.map(item => item.name.toUpperCase());
まとめ
| タスク | 命令型 | 関数型 | 推奨 |
|---|---|---|---|
| 配列要素を変換する | push を使った for ループ | .map() | map を使う |
| 配列をフィルタリングする | 条件を指定した for ループ | .filter() | filter を使う |
| 値を累積する | アキュムレーターを使った for ループ | .reduce() | 複雑な場合は reduce、単純な場合はループを使う |
| キーでグループ化する | オブジェクトを使った for ループ | groupBy ユーティリティ | 再利用可能なユーティリティを作成する |
| オブジェクトのフィールドを選択する | プロパティを手動でコピー | pick ユーティリティ | 1 回限りならスプレッド構文、繰り返すならユーティリティを使う |
| オブジェクトをマージする | プロパティごとに処理 | スプレッド構文 | スプレッド構文を使う |
| 深くマージする | ネストした条件分岐 | 再帰的なユーティリティ | ユーティリティまたはライブラリを使う |
| null 安全にアクセスする | if (x && x.y) | ?. または Option | 単純な場合は ?.、合成する場合は Option を使う |
| API データを正規化する | ネストしたループ | 抽出関数 | 合成可能な関数に分割する |
関数型のアプローチが適している場合:
- 操作を合成する必要がある
- 再利用可能な変換が必要である
- 暗黙的な状態よりも、明示的なデータフローを重視する
- 欠損値の型安全性が重要である
命令型のアプローチでも問題ない場合:
- 変換が 1 回限りである
- ロジックが単純で直線的である
- パフォーマンスが重要で、計測も済んでいる
- チームがこちらのほうに慣れている