Skip to content

Commit bf63585

Browse files
authored
Merge pull request #2 from cometloop/feat/docs
feat: add withObjects function; doc updates
2 parents 5838d05 + 73bf607 commit bf63585

File tree

31 files changed

+3766
-3026
lines changed

31 files changed

+3766
-3026
lines changed

README.md

Lines changed: 53 additions & 2611 deletions
Large diffs are not rendered by default.

apps/documentation-website/src/app/docs/comparison/page.md

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: Comparison with try-catch
33
---
44

5-
See how `safe` compares to traditional try-catch error handling. {% .lead %}
5+
See how `@cometloop/safe` compares to traditional try-catch error handling — and how `createSafe` keeps call sites as clean as possible. {% .lead %}
66

77
---
88

@@ -43,18 +43,35 @@ async function processOrder(orderId: string) {
4343

4444
---
4545

46-
## With safe utilities
46+
## With createSafe (recommended)
47+
48+
The best way to use `@cometloop/safe` is with `createSafe`. Configure error handling once, wrap your functions, and the call site looks like normal code:
49+
50+
```ts
51+
// Configure once — in a shared module like lib/safe.ts
52+
const appSafe = createSafe({
53+
parseError: (e) => ({
54+
code: e instanceof Error ? e.name : 'UNKNOWN',
55+
message: e instanceof Error ? e.message : String(e),
56+
}),
57+
defaultError: { code: 'UNKNOWN', message: 'Unknown error' },
58+
})
59+
60+
// Wrap functions once
61+
const safeFetchOrder = appSafe.wrapAsync(fetchOrder)
62+
const safeProcessPayment = appSafe.wrapAsync(processPayment)
63+
```
4764

4865
```ts
49-
// Benefits: typed errors, flat structure, explicit handling
66+
// Call site — clean and minimal, just like normal function calls
5067
async function processOrder(orderId: string) {
51-
const [order, fetchError] = await safe.async(() => fetchOrder(orderId))
68+
const [order, fetchError] = await safeFetchOrder(orderId)
5269
if (fetchError) {
5370
console.error('Failed to fetch order:', fetchError.message)
5471
return null
5572
}
5673

57-
const [payment, paymentError] = await safe.async(() => processPayment(order))
74+
const [payment, paymentError] = await safeProcessPayment(order)
5875
if (paymentError) {
5976
console.error('Payment failed:', paymentError.message)
6077
return null
@@ -64,8 +81,9 @@ async function processOrder(orderId: string) {
6481
}
6582
```
6683

67-
**Benefits of safe:**
84+
**Benefits:**
6885

86+
- **Clean call sites** — looks like calling a normal function, no extra noise
6987
- Errors are **typed** — TypeScript knows the exact error shape
7088
- **Flat structure** — no nesting, reads top to bottom
7189
- **Explicit** — errors are part of the return type, impossible to forget
@@ -97,17 +115,18 @@ if (result === null) {
97115
}
98116
```
99117

100-
**safe.wrap:**
118+
**createSafe + wrap:**
101119

102120
```ts
103-
const safeDivide = safe.wrap((a: number, b: number) => {
121+
const safeDivide = appSafe.wrap((a: number, b: number) => {
104122
if (b === 0) throw new Error('Division by zero')
105123
return a / b
106124
})
107125

126+
// Call site is clean — just a function call
108127
const [result, error] = safeDivide(10, 2)
109128
if (error) {
110-
// Unambiguous: this was an error
129+
// Unambiguous: this was an error, fully typed
111130
console.error(error.message)
112131
}
113132
```
@@ -134,34 +153,21 @@ async function getUser(id: string) {
134153
}
135154
```
136155

137-
**safe.async:**
156+
**createSafe + wrapAsync:**
138157

139158
```ts
140-
type ApiError = {
141-
type: 'NETWORK' | 'HTTP' | 'PARSE' | 'UNKNOWN'
142-
message: string
143-
}
144-
145-
const getUser = async (id: string) => {
146-
const [user, error] = await safe.async(
147-
async () => {
148-
const response = await fetch(`/api/users/${id}`)
149-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
150-
return response.json()
151-
},
152-
(e): ApiError => ({
153-
type: e instanceof TypeError ? 'NETWORK' : 'HTTP',
154-
message: e instanceof Error ? e.message : 'Unknown error',
155-
})
156-
)
157-
158-
if (error) {
159-
// error is fully typed as ApiError
160-
console.error(`${error.type}: ${error.message}`)
161-
return null
162-
}
159+
// Error mapping is configured once in createSafe — not repeated here
160+
const safeGetUser = appSafe.wrapAsync(async (id: string) => {
161+
const response = await fetch(`/api/users/${id}`)
162+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
163+
return response.json()
164+
})
163165

164-
return user
166+
// Call site is minimal — just call the function
167+
const [user, error] = await safeGetUser('42')
168+
if (error) {
169+
// error is fully typed — no `unknown`, no manual narrowing
170+
console.error(`${error.code}: ${error.message}`)
165171
}
166172
```
167173

apps/documentation-website/src/app/docs/create-safe-examples/page.md

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import * as Sentry from '@sentry/node'
1717
type AppError = {
1818
code: string
1919
message: string
20-
timestamp: Date
2120
requestId?: string
2221
stack?: string
2322
}
@@ -28,14 +27,12 @@ export const appSafe = createSafe({
2827
return {
2928
code: error.name === 'Error' ? 'UNKNOWN_ERROR' : error.name,
3029
message: error.message,
31-
timestamp: new Date(),
3230
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
3331
}
3432
},
3533
defaultError: {
3634
code: 'UNKNOWN_ERROR',
3735
message: 'An unknown error occurred',
38-
timestamp: new Date(),
3936
},
4037
onSuccess: (result) => {
4138
metrics.increment('operation.success')
@@ -44,7 +41,6 @@ export const appSafe = createSafe({
4441
logger.error('Operation failed', {
4542
code: error.code,
4643
message: error.message,
47-
timestamp: error.timestamp,
4844
})
4945

5046
Sentry.captureException(new Error(error.message), {
@@ -55,9 +51,12 @@ export const appSafe = createSafe({
5551
},
5652
})
5753

58-
// Usage throughout your application
59-
const [user, error] = await appSafe.async(() => userService.findById(id))
60-
const [config, parseError] = appSafe.sync(() => JSON.parse(configString))
54+
// Wrap functions for reuse throughout your application
55+
const safeFindUser = appSafe.wrapAsync(userService.findById.bind(userService))
56+
const safeJsonParse = appSafe.wrap(JSON.parse)
57+
58+
const [user, error] = await safeFindUser(id)
59+
const [config, parseError] = safeJsonParse(configString)
6160
```
6261

6362
---
@@ -352,7 +351,6 @@ type TenantError = {
352351
code: string
353352
message: string
354353
tenantId: string
355-
timestamp: Date
356354
}
357355

358356
function createTenantSafe(tenantId: string): SafeInstance<TenantError> {
@@ -361,13 +359,11 @@ function createTenantSafe(tenantId: string): SafeInstance<TenantError> {
361359
code: e instanceof Error ? e.name : 'UNKNOWN',
362360
message: e instanceof Error ? e.message : String(e),
363361
tenantId,
364-
timestamp: new Date(),
365362
}),
366363
defaultError: {
367364
code: 'UNKNOWN',
368365
message: 'Unknown error',
369366
tenantId,
370-
timestamp: new Date(),
371367
},
372368
onSuccess: () => {
373369
metrics.increment('tenant.operation.success', { tenantId })
@@ -393,17 +389,18 @@ class TenantContext {
393389
this.safe = createTenantSafe(tenantId)
394390
}
395391

396-
getUsers = () =>
397-
this.safe.async(() =>
398-
db.users.findMany({ where: { tenantId: this.tenantId } })
399-
)
392+
private async _getUsers() {
393+
return db.users.findMany({ where: { tenantId: this.tenantId } })
394+
}
400395

401-
createDocument = (data: CreateDocumentDto) =>
402-
this.safe.async(() =>
403-
db.documents.create({
404-
data: { ...data, tenantId: this.tenantId },
405-
})
406-
)
396+
private async _createDocument(data: CreateDocumentDto) {
397+
return db.documents.create({
398+
data: { ...data, tenantId: this.tenantId },
399+
})
400+
}
401+
402+
getUsers = this.safe.wrapAsync(this._getUsers.bind(this))
403+
createDocument = this.safe.wrapAsync(this._createDocument.bind(this))
407404
}
408405

409406
// Middleware creates tenant context per request

apps/documentation-website/src/app/docs/create-safe/page.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
title: createSafe
33
---
44

5-
Creates a pre-configured safe instance with a fixed error mapping function and optional default hooks. The error type is automatically inferred from the `parseError` return type. {% .lead %}
5+
The recommended way to use `@cometloop/safe`. Creates a pre-configured safe instance so your call sites stay clean and minimal — just a normal function call, no extra configuration. {% .lead %}
66

77
---
88

99
## When to use createSafe
1010

11-
- You want consistent error mapping across multiple operations
12-
- You need default logging/analytics hooks for all operations
13-
- You want to avoid repeating `parseError` on every call
11+
`createSafe` is the best way to use this library. The goal is simple: **move all error handling configuration out of your call sites** so they read like normal function calls.
12+
13+
- You want call sites that are clean and minimal — no inline `parseError`, no options objects
14+
- You want consistent error mapping across multiple operations without repeating yourself
15+
- You need default logging/analytics hooks that run automatically
16+
- You want to wrap functions once and call them everywhere like any other function
1417

1518
---
1619

@@ -27,6 +30,7 @@ type CreateSafeConfig<E, TResult = never> = {
2730
parseResult?: (result: unknown) => TResult // Optional: transforms successful results
2831
onSuccess?: (result: unknown) => void // Optional: called on every success
2932
onError?: (error: E) => void // Optional: called on every error
33+
onSettled?: (result: unknown, error: E | null) => void // Optional: called after success or error
3034
onRetry?: (error: E, attempt: number) => void // Optional: called before each retry
3135
retry?: RetryConfig // Optional: default retry config (async only)
3236
abortAfter?: number // Optional: default timeout (async only)
@@ -38,38 +42,36 @@ type CreateSafeConfig<E, TResult = never> = {
3842
3943
## Basic usage
4044
45+
Configure once, then every call site is just a function call:
46+
4147
```ts
4248
import { createSafe } from '@cometloop/safe'
4349

4450
type AppError = {
4551
code: string
4652
message: string
47-
timestamp: Date
4853
}
4954

55+
// All the configuration lives here — once
5056
const appSafe = createSafe({
5157
parseError: (e): AppError => ({
5258
code: 'UNKNOWN_ERROR',
5359
message: e instanceof Error ? e.message : 'An unknown error occurred',
54-
timestamp: new Date(),
5560
}),
5661
defaultError: {
5762
code: 'UNKNOWN_ERROR',
5863
message: 'An unknown error occurred',
59-
timestamp: new Date(),
6064
},
6165
})
6266

63-
// All methods now return AppError on failure
64-
const [data, error] = appSafe.sync(() => JSON.parse(jsonString))
65-
if (error) {
66-
console.error(error.code, error.message) // error is typed as AppError
67-
}
67+
// Wrap your functions
68+
const safeJsonParse = appSafe.wrap(JSON.parse)
69+
const safeFetchUser = appSafe.wrapAsync(fetchUser)
6870

69-
const [user, err] = await appSafe.async(() => fetchUser(id))
70-
if (err) {
71-
console.error(err.code) // err is typed as AppError
72-
}
71+
// Call sites are clean — just like calling a normal function
72+
const [data, error] = safeJsonParse(jsonString)
73+
const [user, err] = await safeFetchUser(id)
74+
// error and err are fully typed as AppError
7375
```
7476

7577
---
@@ -83,12 +85,10 @@ const loggingSafe = createSafe({
8385
parseError: (e): AppError => ({
8486
code: 'ERROR',
8587
message: e instanceof Error ? e.message : String(e),
86-
timestamp: new Date(),
8788
}),
8889
defaultError: {
8990
code: 'ERROR',
9091
message: 'An unknown error occurred',
91-
timestamp: new Date(),
9292
},
9393
onSuccess: (result) => {
9494
console.log('Operation succeeded:', result)
@@ -308,5 +308,5 @@ appSafe.sync(
308308
```
309309

310310
{% callout title="SafeInstance type" type="note" %}
311-
The returned instance has `sync`, `async`, `wrap`, and `wrapAsync` methods — all pre-configured with the error type. See [Types](/docs/types) for the full `SafeInstance<E>` definition.
311+
The returned instance has `sync`, `async`, `wrap`, `wrapAsync`, `all`, and `allSettled` methods — all pre-configured with the error type. The `all` and `allSettled` methods accept raw async functions (not pre-wrapped `Promise<SafeResult>` entries). See [Types](/docs/types) for the full `SafeInstance<E>` definition.
312312
{% /callout %}

apps/documentation-website/src/app/docs/error-mapping/page.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,16 @@ const dbSafe = createSafe({
115115
parseError: (e) => ({
116116
code: e instanceof Error ? e.name : 'UNKNOWN',
117117
message: e instanceof Error ? e.message : String(e),
118-
timestamp: new Date(),
119118
}),
120-
defaultError: { code: 'UNKNOWN', message: 'Unknown error', timestamp: new Date() },
119+
defaultError: { code: 'UNKNOWN', message: 'Unknown error' },
121120
})
122121

123122
// All operations use the same error mapper
124-
const [user, error] = await dbSafe.async(() => db.user.findById(id))
125-
const [order, error2] = await dbSafe.async(() => db.order.findById(orderId))
123+
const safeFindUser = dbSafe.wrapAsync(db.user.findById.bind(db.user))
124+
const safeFindOrder = dbSafe.wrapAsync(db.order.findById.bind(db.order))
125+
126+
const [user, error] = await safeFindUser(id)
127+
const [order, error2] = await safeFindOrder(orderId)
126128
```
127129

128130
{% callout title="TypeScript inference" type="note" %}

apps/documentation-website/src/app/docs/hooks/page.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,22 @@ const [length, error] = safe.sync(
159159

160160
### Error handling
161161

162-
If `parseResult` throws, the error is caught and processed through `parseError`. For async operations with retry, a `parseResult` throw counts as a failure and triggers retry logic.
162+
If `parseResult` throws, the error is routed through the standard error path — just like any error thrown by the wrapped function. It goes through `parseError` (if provided), triggers `onError` and `onSettled`, and returns `[null, error]`.
163163

164164
```ts
165165
const [data, error] = safe.sync(
166-
() => '{"valid": true}',
167-
(e) => ({ code: 'PARSE_ERROR', message: String(e) }),
166+
() => 42,
167+
(e) => ({ code: 'TRANSFORM_FAILED', message: String(e) }),
168168
{
169-
parseResult: (raw) => JSON.parse(raw),
169+
parseResult: (n) => {
170+
throw new Error('transform failed')
171+
},
172+
onError: (err) => {
173+
// err is { code: 'TRANSFORM_FAILED', message: 'Error: transform failed' }
174+
},
170175
}
171176
)
177+
// data is null, error is { code: 'TRANSFORM_FAILED', message: '...' }
172178
```
173179

174180
{% callout title="parseResult vs hooks" type="note" %}

0 commit comments

Comments
 (0)