SkillDevelopment & Engineering
Trace a request through a codebase
Follow one request from entry point to storage or external boundary with concrete file references.
✳Claude Code
By Dave AIDave AI original
MIT · FreeView skill
Practical async patterns using TaskEither - clean pipelines instead of try/catch hell, with real API examples
---
name: fp-async
description: Practical async patterns using TaskEither - clean pipelines instead of try/catch hell, with real API examples
risk: critical
source: community
date_added: "2026-03-06"
version: 1.0.0
author: kadu
tags:
- fp-ts
- typescript
- async
- error-handling
- practical
- promises
- api
- fetch
---
# Practical Async Patterns with fp-ts
Stop writing nested try/catch blocks. Stop losing error context. Start building clean async pipelines that handle errors properly.
**TaskEither is simply an async operation that tracks success or failure.** That's it. No fancy terminology needed.
## Detailed Guide
Read [the detailed guide](references/detailed-guide.md) before executing this skill. It retains the complete procedure and reference material. Treat its safety, prerequisites, and validation requirements as mandatory. For focused work, load the relevant sections; for end-to-end work, read the guide completely.
## When to Use
- You need async error handling in TypeScript with `TaskEither`.
- The task involves wrapping Promises, composing API calls, or replacing nested `try/catch` flows.
- You want practical fp-ts async patterns instead of academic explanations.
```typescript
// TaskEither<Error, User> means:
// "An async operation that either fails with Error or succeeds with User"
```
---
## 5. Real API Examples
### Complete Fetch Wrapper
```typescript
// types.ts
interface ApiError {
code: string
message: string
status: number
details?: unknown
}
// api.ts
const createApiError = (
code: string,
message: string,
status: number,
details?: unknown
): ApiError => ({ code, message, status, details })
const request = <T>(
url: string,
options: RequestInit = {}
): TE.TaskEither<ApiError, T> =>
TE.tryCatch(
async () => {
const headers = new Headers(options.headers)
if (options.body != null && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
const response = await fetch(url, {
...options, headers,
signal: options.signal ?? AbortSignal.timeout(10000),
})
if (!response.ok) {
const parsed: unknown = await response.json().catch(() => ({}))
const body = typeof parsed === 'object' && parsed !== null
? parsed as Record<string, unknown> : {}
throw createApiError(
typeof body.code === 'string' ? body.code : 'HTTP_ERROR',
typeof body.message === 'string' ? body.message : response.statusText,
response.status,
body
)
}
// Handle 204 No Content
if (response.status === 204) {
throw createApiError("EMPTY_RESPONSE", "Expected JSON; use a separate void decoder for 204", 204)
}
return response.json() // Decode unknown with a runtime schema before trusting T
},
(error): ApiError => {
if (typeof error === 'object' && error !== null && 'code' in error) {
return error as ApiError
}
return createApiError(
'NETWORK_ERROR',
error instanceof Error ? error.message : 'Request failed',
0
)
}
)
// API client
const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, body: unknown) =>
request<T>(url, {
method: 'POST',
body: JSON.stringify(body)
}),
put: <T>(url: string, body: unknown) =>
request<T>(url, {
method: 'PUT',
body: JSON.stringify(body)
}),
delete: (url: string) =>
request<void>(url, { method: 'DELETE' }),
}
// Usage
const getUser = (id: string) => api.get<User>(`/api/users/${encodeURIComponent(id)}`)
const createUser = (data: CreateUserDto) => api.post<User>('/api/users', data)
const updateUser = (id: string, data: UpdateUserDto) => api.put<User>(`/api/users/${encodeURIComponent(id)}`, data)
const deleteUser = (id: string) => api.delete(`/api/users/${encodeURIComponent(id)}`)
```
### Database Operations (Prisma Example)
```typescript
import { PrismaClient, Prisma } from '@prisma/client'
type DbError =
| { _tag: 'NotFound'; entity: string; id: string }
| { _tag: 'UniqueViolation'; field: string }
| { _tag: 'ConnectionError'; cause: unknown }
const prisma = new PrismaClient()
const wrapPrisma = <T>(
operation: () => Promise<T>
): TE.TaskEither<DbError, T> =>
TE.tryCatch(
operation,
(error): DbError => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const target = error.meta?.target
const field = Array.isArray(target) && target.every(x => typeof x === 'string')
? target.join(', ') : 'unknown'
return { _tag: 'UniqueViolation', field }
}
if (error.code === 'P2025') {
return { _tag: 'NotFound', entity: 'Record', id: 'unknown' }
}
}
return { _tag: 'ConnectionError', cause: error }
}
)
// Repository pattern
const userRepository = {
findById: (id: string): TE.TaskEither<DbError, User> =>
pipe(
wrapPrisma(() => prisma.user.findUnique({ where: { id } })),
TE.chain(user =>
user
? TE.right(user)
: TE.left({ _tag: 'NotFound', entity: 'User', id })
)
),
findByEmail: (email: string): TE.TaskEither<DbError, User | null> =>
wrapPrisma(() => prisma.user.findUnique({ where: { email } })),
create: (data: CreateUserInput): TE.TaskEither<DbError, User> =>
wrapPrisma(() => prisma.user.create({ data })),
update: (id: string, data: UpdateUserInput): TE.TaskEither<DbError, User> =>
wrapPrisma(() => prisma.user.update({ where: { id }, data })),
delete: (id: string): TE.TaskEither<DbError, void> =>
pipe(
wrapPrisma(() => prisma.user.delete({ where: { id } })),
TE.map(() => undefined)
),
}
// Service using repository
const createUserService = (input: CreateUserInput) =>
pipe(
// Check email doesn't exist
userRepository.findByEmail(input.email),
TE.chain(existing =>
existing
? TE.left({ _tag: 'UniqueViolation' as const, field: 'email' })
: TE.right(undefined)
),
// Create user
TE.chain(() => userRepository.create(input))
)
```
### File Operations (Node.js)
```typescript
import * as fs from 'fs/promises'
import * as path from 'path'
type FileError =
| { _tag: 'NotFound'; path: string }
| { _tag: 'PermissionDenied'; path: string }
| { _tag: 'IoError'; cause: unknown }
const toFileError = (error: unknown, filePath: string): FileError => {
if (error instanceof Error) {
if ('code' in error) {
if (error.code === 'ENOENT') return { _tag: 'NotFound', path: filePath }
if (error.code === 'EACCES') return { _tag: 'PermissionDenied', path: filePath }
}
}
return { _tag: 'IoError', cause: error }
}
const readFile = (filePath: string): TE.TaskEither<FileError, string> =>
TE.tryCatch(
() => fs.readFile(filePath, 'utf-8'),
(e) => toFileError(e, filePath)
)
const writeFile = (filePath: string, content: string): TE.TaskEither<FileError, void> =>
TE.tryCatch(
() => fs.writeFile(filePath, content, 'utf-8'),
(e) => toFileError(e, filePath)
)
const readJson = <T>(filePath: string): TE.TaskEither<FileError | { _tag: 'ParseError'; cause: unknown }, T> =>
pipe(
readFile(filePath),
TE.chainW(content =>
TE.tryCatch(
() => Promise.resolve(JSON.parse(content)),
(e): { _tag: 'ParseError'; cause: unknown } => ({ _tag: 'ParseError', cause: e })
)
)
)
// Usage: Load config with fallback
const loadConfig = () =>
pipe(
readJson<Config>('./config.json'),
TE.orElse(() => readJson<Config>('./config.default.json')),
TE.getOrElse(() => T.of(defaultConfig))
)
```
---
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
## Execution and data boundaries
These are separate fp-ts 2.x teaching fragments, with omitted imports, domain types, runtime decoders and application helpers. TaskEither is lazy: calling the returned task again repeats effects, and a pre-created Promise is not a fresh retry. The corrected batch traversal is sequential between batches; parallel traversal does not cancel in-flight work. Validate unknown JSON instead of asserting a generic T, and handle no-content responses through an explicit void decoder.
A pipeline is not a database transaction or payment saga: preserve inventory checks, use durable/provider idempotency, and reconcile failed shipment or ambiguous charge outcomes. Keep uniqueness constraints even after a pre-check. Retry only classified transient, safely repeatable operations with an overall deadline and jitter; the generic example retries every Left. Do not recover authorization, malformed security configuration or invalid data with defaults; branch on typed status rather than message substrings. File paths need an authorized root, and writes currently overwrite existing files. Map errors to safe public status/messages without serializing raw causes or database details.
---
name: fp-async
description: Practical async patterns using TaskEither - clean pipelines instead of try/catch hell, with real API examples
risk: critical
source: community
date_added: "2026-03-06"
version: 1.0.0
author: kadu
tags:
- fp-ts
- typescript
- async
- error-handling
- practical
- promises
- api
- fetch
---
# Practical Async Patterns with fp-ts
Stop writing nested try/catch blocks. Stop losing error context. Start building clean async pipelines that handle errors properly.
**TaskEither is simply an async operation that tracks success or failure.** That's it. No fancy terminology needed.
## Detailed Guide
Read [the detailed guide](references/detailed-guide.md) before executing this skill. It retains the complete procedure and reference material. Treat its safety, prerequisites, and validation requirements as mandatory. For focused work, load the relevant sections; for end-to-end work, read the guide completely.
## When to Use
- You need async error handling in TypeScript with `TaskEither`.
- The task involves wrapping Promises, composing API calls, or replacing nested `try/catch` flows.
- You want practical fp-ts async patterns instead of academic explanations.
```typescript
// TaskEither<Error, User> means:
// "An async operation that either fails with Error or succeeds with User"
```
---
## 5. Real API Examples
### Complete Fetch Wrapper
```typescript
// types.ts
interface ApiError {
code: string
message: string
status: number
details?: unknown
}
// api.ts
const createApiError = (
code: string,
message: string,
status: number,
details?: unknown
): ApiError => ({ code, message, status, details })
const request = <T>(
url: string,
options: RequestInit = {}
): TE.TaskEither<ApiError, T> =>
TE.tryCatch(
async () => {
const headers = new Headers(options.headers)
if (options.body != null && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
const response = await fetch(url, {
...options, headers,
signal: options.signal ?? AbortSignal.timeout(10000),
})
if (!response.ok) {
const parsed: unknown = await response.json().catch(() => ({}))
const body = typeof parsed === 'object' && parsed !== null
? parsed as Record<string, unknown> : {}
throw createApiError(
typeof body.code === 'string' ? body.code : 'HTTP_ERROR',
typeof body.message === 'string' ? body.message : response.statusText,
response.status,
body
)
}
// Handle 204 No Content
if (response.status === 204) {
throw createApiError("EMPTY_RESPONSE", "Expected JSON; use a separate void decoder for 204", 204)
}
return response.json() // Decode unknown with a runtime schema before trusting T
},
(error): ApiError => {
if (typeof error === 'object' && error !== null && 'code' in error) {
return error as ApiError
}
return createApiError(
'NETWORK_ERROR',
error instanceof Error ? error.message : 'Request failed',
0
)
}
)
// API client
const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, body: unknown) =>
request<T>(url, {
method: 'POST',
body: JSON.stringify(body)
}),
put: <T>(url: string, body: unknown) =>
request<T>(url, {
method: 'PUT',
body: JSON.stringify(body)
}),
delete: (url: string) =>
request<void>(url, { method: 'DELETE' }),
}
// Usage
const getUser = (id: string) => api.get<User>(`/api/users/${encodeURIComponent(id)}`)
const createUser = (data: CreateUserDto) => api.post<User>('/api/users', data)
const updateUser = (id: string, data: UpdateUserDto) => api.put<User>(`/api/users/${encodeURIComponent(id)}`, data)
const deleteUser = (id: string) => api.delete(`/api/users/${encodeURIComponent(id)}`)
```
### Database Operations (Prisma Example)
```typescript
import { PrismaClient, Prisma } from '@prisma/client'
type DbError =
| { _tag: 'NotFound'; entity: string; id: string }
| { _tag: 'UniqueViolation'; field: string }
| { _tag: 'ConnectionError'; cause: unknown }
const prisma = new PrismaClient()
const wrapPrisma = <T>(
operation: () => Promise<T>
): TE.TaskEither<DbError, T> =>
TE.tryCatch(
operation,
(error): DbError => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const target = error.meta?.target
const field = Array.isArray(target) && target.every(x => typeof x === 'string')
? target.join(', ') : 'unknown'
return { _tag: 'UniqueViolation', field }
}
if (error.code === 'P2025') {
return { _tag: 'NotFound', entity: 'Record', id: 'unknown' }
}
}
return { _tag: 'ConnectionError', cause: error }
}
)
// Repository pattern
const userRepository = {
findById: (id: string): TE.TaskEither<DbError, User> =>
pipe(
wrapPrisma(() => prisma.user.findUnique({ where: { id } })),
TE.chain(user =>
user
? TE.right(user)
: TE.left({ _tag: 'NotFound', entity: 'User', id })
)
),
findByEmail: (email: string): TE.TaskEither<DbError, User | null> =>
wrapPrisma(() => prisma.user.findUnique({ where: { email } })),
create: (data: CreateUserInput): TE.TaskEither<DbError, User> =>
wrapPrisma(() => prisma.user.create({ data })),
update: (id: string, data: UpdateUserInput): TE.TaskEither<DbError, User> =>
wrapPrisma(() => prisma.user.update({ where: { id }, data })),
delete: (id: string): TE.TaskEither<DbError, void> =>
pipe(
wrapPrisma(() => prisma.user.delete({ where: { id } })),
TE.map(() => undefined)
),
}
// Service using repository
const createUserService = (input: CreateUserInput) =>
pipe(
// Check email doesn't exist
userRepository.findByEmail(input.email),
TE.chain(existing =>
existing
? TE.left({ _tag: 'UniqueViolation' as const, field: 'email' })
: TE.right(undefined)
),
// Create user
TE.chain(() => userRepository.create(input))
)
```
### File Operations (Node.js)
```typescript
import * as fs from 'fs/promises'
import * as path from 'path'
type FileError =
| { _tag: 'NotFound'; path: string }
| { _tag: 'PermissionDenied'; path: string }
| { _tag: 'IoError'; cause: unknown }
const toFileError = (error: unknown, filePath: string): FileError => {
if (error instanceof Error) {
if ('code' in error) {
if (error.code === 'ENOENT') return { _tag: 'NotFound', path: filePath }
if (error.code === 'EACCES') return { _tag: 'PermissionDenied', path: filePath }
}
}
return { _tag: 'IoError', cause: error }
}
const readFile = (filePath: string): TE.TaskEither<FileError, string> =>
TE.tryCatch(
() => fs.readFile(filePath, 'utf-8'),
(e) => toFileError(e, filePath)
)
const writeFile = (filePath: string, content: string): TE.TaskEither<FileError, void> =>
TE.tryCatch(
() => fs.writeFile(filePath, content, 'utf-8'),
(e) => toFileError(e, filePath)
)
const readJson = <T>(filePath: string): TE.TaskEither<FileError | { _tag: 'ParseError'; cause: unknown }, T> =>
pipe(
readFile(filePath),
TE.chainW(content =>
TE.tryCatch(
() => Promise.resolve(JSON.parse(content)),
(e): { _tag: 'ParseError'; cause: unknown } => ({ _tag: 'ParseError', cause: e })
)
)
)
// Usage: Load config with fallback
const loadConfig = () =>
pipe(
readJson<Config>('./config.json'),
TE.orElse(() => readJson<Config>('./config.default.json')),
TE.getOrElse(() => T.of(defaultConfig))
)
```
---
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
## Execution and data boundaries
These are separate fp-ts 2.x teaching fragments, with omitted imports, domain types, runtime decoders and application helpers. TaskEither is lazy: calling the returned task again repeats effects, and a pre-created Promise is not a fresh retry. The corrected batch traversal is sequential between batches; parallel traversal does not cancel in-flight work. Validate unknown JSON instead of asserting a generic T, and handle no-content responses through an explicit void decoder.
A pipeline is not a database transaction or payment saga: preserve inventory checks, use durable/provider idempotency, and reconcile failed shipment or ambiguous charge outcomes. Keep uniqueness constraints even after a pre-check. Retry only classified transient, safely repeatable operations with an overall deadline and jitter; the generic example retries every Left. Do not recover authorization, malformed security configuration or invalid data with defaults; branch on typed status rather than message substrings. File paths need an authorized root, and writes currently overwrite existing files. Map errors to safe public status/messages without serializing raw causes or database details.
Imported SKILL.md format for Claude Code, Codex and Cursor. Review the complete instructions and required tools before use. Package integrity checked; individual agent behavior has not been tested.