Practical Data Transformations by whatiskadudoing/fp-ts-skills
npx skills add https://github.com/whatiskadudoing/fp-ts-skills --skill 'Practical Data Transformations'本技能涵盖日常工作中常见的数据转换操作:处理数组、重塑对象、规范化 API 响应、数据分组以及安全访问嵌套值。每个部分首先展示命令式方法,然后是函数式等效方法,并对每种方法的适用场景进行客观评估。
数组操作是数据转换的基础。让我们用富有表现力、可链式调用的操作来替代冗长的循环。
任务:将一组以分为单位的价格数组转换为以美元为单位。
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 表示“转换每个元素”。转换逻辑()被命名且可重用。无需管理索引,无需手动构建数组。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
toDollars任务:从列表中获取所有活跃用户。
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);
// 或者对于简单的谓词使用内联方式
const activeUsers = users.filter(user => user.isActive);
此处函数式方法更优的原因:谓词(isActive)与迭代逻辑分离。你可以独立地重用、测试和组合谓词。
任务:计算购物车中商品的总价。
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
);
// 或者拆分出行项总价计算
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));
// 或者使用命名的谓词以便重用
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 提供了额外的数组工具,支持更好的组合:
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
// 安全获取头部(第一个元素)
const first = pipe(
[1, 2, 3],
A.head
); // Some(1)
const firstOfEmpty = pipe(
[] as number[],
A.head
); // None
// 按索引安全查找
const third = pipe(
['a', 'b', 'c', 'd'],
A.lookup(2)
); // Some('c')
// 使用谓词查找
const found = pipe(
users,
A.findFirst(user => user.id === 'abc123')
); // Option<User>
// 分割成两组
const [inactive, active] = pipe(
users,
A.partition(user => user.isActive)
);
// 获取前 N 个元素
const topThree = pipe(
sortedScores,
A.takeLeft(3)
);
// 唯一值
const uniqueTags = pipe(
allTags,
A.uniq({ equals: (a, b) => a === b })
);
对象需要不断重塑:选取字段、省略敏感数据、合并设置以及更新嵌套值。
任务:仅从用户对象中提取公共字段。
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,
};
}
// 通用的 pick 工具函数
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 工具函数可在整个代码库中重用。类型安全确保你只能选取存在的键。
任务:在记录日志前移除敏感字段。
function sanitizeForLogging(user: User): Omit<User, 'passwordHash' | 'internalNotes'> {
const { passwordHash, internalNotes, ...safe } = user;
return safe;
}
// 通用的 omit 工具函数
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 工具函数会带来回报。
任务:将用户设置与默认设置合并。
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,
});
// 用法
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' }
此处函数式方法更优的原因:展开语法简洁,可以处理任意数量的键。后面的展开会覆盖前面的,为你提供了自然的“带覆盖的默认值”行为。
任务:合并嵌套的配置对象。
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;
}
// 针对一级嵌套的通用深度合并
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;
};
// 用法
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 保留,api.timeout 被覆盖
// ui.theme 被覆盖,ui.animations 保留
任务:在不发生突变的情况下更新深度嵌套的值。
interface State {
user: {
profile: {
settings: {
theme: string;
};
};
};
}
function updateTheme(state: State, newTheme: string): void {
state.user.profile.settings.theme = newTheme; // 突变!
}
// 手动展开嵌套
const updateTheme = (state: State, newTheme: string): State => ({
...state,
user: {
...state.user,
profile: {
...state.user.profile,
settings: {
...state.user.profile.settings,
theme: newTheme,
},
},
},
});
// 使用类似透镜的辅助函数
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 透镜这样的库。函数式方法的冗长是不可变性的代价。
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) {
// 提取客户
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);
}
// 提取产品并构建项目 ID
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);
}
// 添加规范化订单
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';
// 创建规范化集合的辅助函数
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),
});
// 提取实体
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,
}));
// 组合成最终的规范化函数
const normalizeApiResponse = (response: ApiResponse): NormalizedState => ({
orders: createNormalizedCollection(extractOrders(response.orders)),
customers: createNormalizedCollection(extractCustomers(response.orders)),
products: createNormalizedCollection(extractProducts(response.orders)),
});
此处函数式方法更优的原因:每个提取操作都是独立且可测试的。createNormalizedCollection 辅助函数是可重用的。添加新的实体类型意味着添加一个新的提取函数。
任务:将 API 数据转换为组件所需的数据。
// API 提供的数据
interface ApiUser {
user_id: string;
first_name: string;
last_name: string;
email_address: string;
created_at: string; // ISO 字符串
avatar_url: string | null;
}
// 组件需要的数据
interface DisplayUser {
id: string;
fullName: string;
email: string;
memberSince: string; // "Jan 2024"
avatarUrl: string; // 带后备值
}
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,
});
// 转换用户数组
const toDisplayUsers = (apiUsers: ApiUser[]): DisplayUser[] =>
apiUsers.map(toDisplayUser);
分组和聚合数据对于报告、仪表板和分析至关重要。
任务:按客户对订单进行分组。
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;
}
// 通用的 groupBy 工具函数
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[]>
);
// 用法
const groupByCustomer = groupBy<Order, string>(order => order.customerId);
const ordersByCustomer = groupByCustomer(orders);
// 或者内联使用
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 保证结果中的数组非空
const ordersByCustomer = pipe(
orders as NEA.NonEmptyArray<Order>, // 必须是非空数组
NEA.groupBy(order => order.customerId)
); // Record<string, NonEmptyArray<Order>>
任务:按状态统计订单数量。
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;
}
// 通用的 countBy 工具函数
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>
);
// 用法
const orderCountByStatus = countBy((order: Order) => order.status)(orders);
// { pending: 5, shipped: 12, delivered: 8 }
任务:计算每个产品类别的总收入。
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;
}
// 通用的 sumBy 工具函数
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>
);
// 用法
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,
};
};
// 使用 fp-ts pipe 提高清晰度
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),
};
};
停止编写 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';
}
const getDatabaseHost = (config: Config): string =>
config.database?.connection?.host ?? 'localhost';
客观评估:对于简单的访问模式,可选链(?.)是完美的。它内置于语言中,非常易读。当你需要对可能缺失的值进行组合操作时,使用 fp-ts Option。
在以下情况下使用 fp-ts Option:
你需要对可能缺失的值进行多个链式操作
你想区分“缺失”与其他假值
你正在构建一个转换管道
import * as O from 'fp-ts/Option'; import { pipe } from 'fp-ts/function';
// 返回 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;
// 使用 flatMap 链式访问 const getDatabaseHost = (config: Config): O.Option<string> => pipe( O.some(config), O.flatMap(prop('database')), O.flatMap(prop('connection')), O.flatMap(prop('host')) );
// 使用默认值提取 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';
// 命令式:如果数组为空则抛出错误
const first = items[0]; // 可能是 undefined!
// 安全:返回 Option
const first = A.head(items); // Option<Item>
// 获取第一个项目的名称,或默认值
const firstName = pipe(
items,
A.head,
O.map(item => item.name),
O.getOrElse(() => 'No items')
);
// 按索引安全查找
const third = pipe(
items,
A.lookup(2),
O.map(item => item.name),
O.getOrElse(() => 'Not found')
);
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: 'alice@example.com' },
'user-2': { name: 'Bob', email: 'bob@example.com' },
};
// 命令式:可能是 undefined
const user = users['user-3']; // User | undefined
// 安全:返回 Option
const user = R.lookup('user-3')(users); // Option<User>
// 获取用户邮箱或默认值
const email = pipe(
users,
R.lookup('user-3'),
O.map(u => u.email),
O.getOrElse(() => 'unknown@example.com')
);
任务:获取用户的显示名称,这需要同时具备名和姓。
interface Profile {
firstName?: string;
lastName?: string;
nickname?: string;
}
// 命令式
function getDisplayName(profile: Profile): string {
if (profile.firstName && profile.lastName) {
return `${profile.firstName} ${profile.lastName}`;
}
if (profile.nickname) {
return profile.nickname;
}
return 'Anonymous';
}
// 使用 Option 的函数式方法
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
const getDisplayName = (profile: Profile): string =>
pipe(
// 首先尝试全名
O.Do,
O.bind('first', () => O.fromNullable(profile.firstName)),
O.bind('last', () => O.fromNullable(profile.lastName)),
O.map(({ first, last }) => `${first} ${last}`),
// 回退到昵称
O.alt(() => O.fromNullable(profile.nickname)),
// 最后,默认为 Anonymous
O.getOrElse(() => 'Anonymous')
);
// API 响应
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';
}
// UI 需要的数据
interface OrderSummary {
id: string;
customerName: string;
itemCount: number;
total: number;
formattedTotal: string;
date: string;
statusLabel: string;
statusColor: string;
}
// 转换
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,
};
};
// 转换所有订单
const toOrderSummaries = (orders: ApiOrder[]): OrderSummary[] =>
orders.map(toOrderSummary);
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.pr
This skill covers the data transformations you do every day: working with arrays, reshaping objects, normalizing API responses, grouping data, and safely accessing nested values. Each section shows the imperative approach first, then the functional equivalent, with honest assessments of when each approach shines.
Array operations are the bread and butter of data transformation. Let's replace verbose loops with expressive, chainable operations.
The Task : Convert an array of prices from cents to dollars.
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]
Why functional is better here : The intent is immediately clear. map says "transform each element." The transformation logic (toDollars) is named and reusable. No index management, no manual array building.
The Task : Get all active users from a list.
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);
Why functional is better here : The predicate (isActive) is separated from the iteration logic. You can reuse, test, and compose predicates independently.
The Task : Calculate the total price of items in a cart.
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);
Honest assessment : For simple sums, the imperative loop is actually quite readable. The functional version shines when you need to compose the accumulation with other transformations, or when the reduction logic is complex enough to benefit from being named.
The Task : Get the names of all active premium users, sorted alphabetically.
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);
Why functional is better here : Each step in the chain has a single responsibility. You can read the transformation as a series of steps: "filter active, filter premium, get names, sort." Adding or removing a step is trivial.
fp-ts provides additional array utilities with better composition support:
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 })
);
Objects need reshaping constantly: picking fields, omitting sensitive data, merging settings, and updating nested values.
The Task : Extract only the public fields from a user object.
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);
Why functional is better here : The pick utility is reusable across your codebase. Type safety ensures you can only pick keys that exist.
The Task : Remove sensitive fields before logging.
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',
]);
Honest assessment : For one-off omits, destructuring (the imperative approach) is perfectly fine and very readable. The functional omit utility pays off when you have many such transformations or need to compose them.
The Task : Merge user settings with defaults.
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' }
Why functional is better here : Spread syntax is concise and handles any number of keys. Later spreads override earlier ones, giving you natural "defaults with overrides" behavior.
The Task : Merge nested configuration objects.
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
The Task : Update a deeply nested value without mutation.
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');
Honest assessment : The spread nesting is verbose but explicit. For deeply nested updates, consider using a library like immer or fp-ts lenses. The verbosity of the functional approach is the price of immutability.
API responses rarely match the shape your app needs. Normalization transforms nested, denormalized data into flat, indexed structures.
The Task : Transform a nested API response into a normalized state.
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)),
});
Why functional is better here : Each extraction is independent and testable. The createNormalizedCollection helper is reusable. Adding a new entity type means adding one new extraction function.
The Task : Convert API data to what your components need.
// 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);
Grouping and aggregating data is essential for reports, dashboards, and analytics.
The Task : Group orders by customer.
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);
Using 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>>
The Task : Count orders by status.
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 }
The Task : Calculate total revenue per product category.
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 }
The Task : Calculate totals from line items with quantity and unit price.
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),
};
};
Stop writing if (x && x.y && x.y.z). Safely navigate nested structures without runtime errors.
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';
}
const getDatabaseHost = (config: Config): string =>
config.database?.connection?.host ?? 'localhost';
Honest assessment : For simple access patterns, optional chaining (?.) is perfect. It's built into the language and very readable. Use fp-ts Option when you need to compose operations on potentially missing values.
Use fp-ts Option when:
You need to chain multiple operations on potentially missing values
You want to distinguish "missing" from other falsy values
You're building a pipeline of transformations
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')
);
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: 'alice@example.com' },
'user-2': { name: 'Bob', email: 'bob@example.com' },
};
// 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(() => 'unknown@example.com')
);
The Task : Get a user's display name, which requires both first and last name.
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')
);
// 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);
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);
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,
}));
};
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',
});
Simple transformations : .map(), .filter(), .reduce() are perfectly good
No composition needed : You're doing a one-off transformation
Team familiarity : Everyone knows native methods
Optional chaining suffices : obj?.prop?.value ?? default handles your null-safety needs
// Native is fine here const activeUserNames = users .filter(u => u.isActive) .map(u => u.name);
Chaining operations that might fail : Multiple steps where each can return nothing
Composing transformations : Building reusable transformation pipelines
Type-safe error handling : You want the compiler to track potential failures
Complex data pipelines : Many steps that benefit from explicit composition
// 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') );
Domain-specific operations : groupBy, countBy, sumBy for your data
Repeated patterns : You find yourself writing the same transformation many times
Team conventions : Establishing consistent patterns across the codebase
// Custom utility pays off when used repeatedly const revenueByRegion = sumBy( (sale: Sale) => sale.region, (sale: Sale) => sale.amount )(sales);
Chaining creates intermediate arrays : arr.filter().map() creates one array, then another
For hot paths, considerreduce: One pass through the data
Measure before optimizing : The readability cost of optimization is often not worth it
// 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());
| Task | Imperative | Functional | Recommendation |
|---|---|---|---|
| Transform array elements | for loop with push | .map() | Use map |
| Filter array | for loop with condition | .filter() | Use filter |
| Accumulate values | for loop with accumulator | .reduce() | Use reduce for complex, loop for simple |
| Group by key | for loop with object | groupBy utility | Create reusable utility |
The functional approach is better when:
The imperative approach is acceptable when:
Weekly Installs
–
Repository
GitHub Stars
4
First Seen
–
Security Audits
智能OCR文字识别工具 - 支持100+语言,高精度提取图片/PDF/手写文本
933 周安装
| Pick object fields | manual property copy | pick utility | Use spread for one-off, utility for repeated |
| Merge objects | property-by-property | spread syntax | Use spread |
| Deep merge | nested conditionals | recursive utility | Use utility or library |
| Null-safe access | if (x && x.y) | ?. or Option | Use ?. for simple, Option for composition |
| Normalize API data | nested loops | extraction functions | Break into composable functions |