wellcrafted 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -203
- package/dist/error/index.d.ts +3 -3
- package/dist/error/index.js +2 -2
- package/dist/{error-Dy9wXt5_.js → error-BkeqDeUq.js} +2 -2
- package/dist/{error-Dy9wXt5_.js.map → error-BkeqDeUq.js.map} +1 -1
- package/dist/function.d.ts +36 -0
- package/dist/function.d.ts.map +1 -0
- package/dist/function.js +46 -0
- package/dist/function.js.map +1 -0
- package/dist/{index-B9PnZCTt.d.ts → index-ByVX3bXz.d.ts} +2 -2
- package/dist/{index-B9PnZCTt.d.ts.map → index-ByVX3bXz.d.ts.map} +1 -1
- package/dist/{index-DnoV2ZDO.d.ts → index-D0f5JWiT.d.ts} +2 -2
- package/dist/{index-DnoV2ZDO.d.ts.map → index-D0f5JWiT.d.ts.map} +1 -1
- package/dist/json.d.ts +5 -5
- package/dist/json.js +4 -4
- package/dist/logger/index.d.ts +3 -3
- package/dist/logger/index.js +2 -2
- package/dist/query/index.d.ts +19 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +15 -15
- package/dist/query/index.js.map +1 -1
- package/dist/result/index.d.ts +3 -3
- package/dist/result/index.js +3 -3
- package/dist/{result-C5cJ1_WU.js → result-C_ph_izC.js} +5 -5
- package/dist/result-C_ph_izC.js.map +1 -0
- package/dist/{result-DKwq9BCr.d.ts → result-_socO0Ud.d.ts} +5 -5
- package/dist/{result-DKwq9BCr.d.ts.map → result-_socO0Ud.d.ts.map} +1 -1
- package/dist/{result-C9V2Knvt.js → result-pV2mfn0W.js} +2 -2
- package/dist/{result-C9V2Knvt.js.map → result-pV2mfn0W.js.map} +1 -1
- package/dist/{tap-err-CP-re1HT.js → tap-err-BENAkFsH.js} +2 -2
- package/dist/{tap-err-CP-re1HT.js.map → tap-err-BENAkFsH.js.map} +1 -1
- package/dist/{tap-err-CFhHBPfH.d.ts → tap-err-C0xfVXtz.d.ts} +2 -2
- package/dist/{tap-err-CFhHBPfH.d.ts.map → tap-err-C0xfVXtz.d.ts.map} +1 -1
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +4 -4
- package/dist/{types-tXXk7K9Q.d.ts → types-ojNaDB4n.d.ts} +2 -2
- package/dist/{types-tXXk7K9Q.d.ts.map → types-ojNaDB4n.d.ts.map} +1 -1
- package/package.json +9 -2
- package/dist/result-C5cJ1_WU.js.map +0 -1
package/README.md
CHANGED
|
@@ -9,84 +9,141 @@
|
|
|
9
9
|
|
|
10
10
|
Tagged errors and Result types as plain objects. < 2KB, zero dependencies.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
`try/catch` throws away your error's type the moment you catch it. You get `catch (error: unknown)` and you're guessing again. And a thrown `Error` travels badly: `JSON.stringify(new Error("boom"))` is `{}`, so the message vanishes the moment it hits a log line, a Web Worker, or an IPC boundary, where `instanceof` stops working too.
|
|
13
13
|
|
|
14
|
-
wellcrafted
|
|
14
|
+
wellcrafted fixes both. Define your errors once as plain data, return them instead of throwing, and check them with the `{ data, error }` shape you already know from Supabase, SvelteKit load functions, and TanStack Query. No `.isOk()` method chains, no `.map().andThen().orElse()` pipelines. Check `error`, use `data`.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
|
-
import { defineErrors
|
|
18
|
-
import {
|
|
17
|
+
import { defineErrors } from "wellcrafted/error";
|
|
18
|
+
import { trySync } from "wellcrafted/result";
|
|
19
|
+
|
|
20
|
+
// The key becomes error.name. The fields you return are typed on the error.
|
|
21
|
+
const { ParseError } = defineErrors({
|
|
22
|
+
ParseError: ({ path }: { path: string }) => ({
|
|
23
|
+
message: `Could not parse ${path}`,
|
|
24
|
+
path,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
const { data, error } = trySync({
|
|
29
|
+
try: () => JSON.parse(raw),
|
|
30
|
+
catch: () => ParseError({ path: "config.json" }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (error) {
|
|
34
|
+
// error is { name: "ParseError"; message: string; path: string }
|
|
35
|
+
console.error(error.message, error.path);
|
|
36
|
+
} else {
|
|
37
|
+
// data is the parsed value, error is null
|
|
38
|
+
use(data);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's the whole idea: define an error, wrap the throwing call, destructure the result, check `error`. A *tagged error* is just an object with a `name` field you can `switch` on. Everything below is that pattern at scale.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install wellcrafted
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## A real service
|
|
51
|
+
|
|
52
|
+
Here is the pattern in shipping code, lightly trimmed from [Whispering](https://github.com/EpicenterHQ/epicenter)'s transcription layer. Each service owns a small vocabulary of things that can go wrong, declared up front with `defineErrors`.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import {
|
|
56
|
+
defineErrors,
|
|
57
|
+
extractErrorMessage,
|
|
58
|
+
type InferErrors,
|
|
59
|
+
} from "wellcrafted/error";
|
|
60
|
+
import { type Result, tryAsync } from "wellcrafted/result";
|
|
61
|
+
|
|
62
|
+
export const ElevenLabsError = defineErrors({
|
|
63
|
+
MissingApiKey: () => ({ message: "ElevenLabs API key is required" }),
|
|
64
|
+
FileTooLarge: ({ sizeMb, maxMb }: { sizeMb: number; maxMb: number }) => ({
|
|
65
|
+
message: `File ${sizeMb.toFixed(1)}MB exceeds ${maxMb}MB limit`,
|
|
66
|
+
sizeMb,
|
|
67
|
+
maxMb,
|
|
25
68
|
}),
|
|
26
|
-
|
|
27
|
-
message:
|
|
28
|
-
email,
|
|
69
|
+
Unexpected: ({ cause }: { cause: unknown }) => ({
|
|
70
|
+
message: extractErrorMessage(cause),
|
|
29
71
|
cause,
|
|
30
72
|
}),
|
|
31
73
|
});
|
|
32
|
-
type
|
|
33
|
-
// ^? { name: "AlreadyExists"; message: string; email: string }
|
|
34
|
-
// | { name: "CreateFailed"; message: string; email: string; cause: unknown }
|
|
74
|
+
export type ElevenLabsError = InferErrors<typeof ElevenLabsError>;
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
76
|
+
async function transcribe(
|
|
77
|
+
audio: Blob,
|
|
78
|
+
apiKey: string,
|
|
79
|
+
): Promise<Result<string, ElevenLabsError>> {
|
|
80
|
+
if (!apiKey) return ElevenLabsError.MissingApiKey(); // a factory already is an Err
|
|
81
|
+
|
|
82
|
+
const sizeMb = audio.size / (1024 * 1024);
|
|
83
|
+
if (sizeMb > 1000) return ElevenLabsError.FileTooLarge({ sizeMb, maxMb: 1000 });
|
|
40
84
|
|
|
41
85
|
return tryAsync({
|
|
42
|
-
try: () =>
|
|
43
|
-
catch: (
|
|
86
|
+
try: () => callElevenLabs(apiKey, audio), // may throw
|
|
87
|
+
catch: (cause) => ElevenLabsError.Unexpected({ cause }),
|
|
44
88
|
});
|
|
45
89
|
}
|
|
90
|
+
```
|
|
46
91
|
|
|
47
|
-
|
|
48
|
-
|
|
92
|
+
The caller checks `error`, then `switch`es on `error.name` to handle each case with the right fields in scope:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const { data, error } = await transcribe(audio, apiKey);
|
|
49
96
|
if (error) {
|
|
50
97
|
switch (error.name) {
|
|
51
|
-
case "
|
|
52
|
-
case "
|
|
53
|
-
|
|
98
|
+
case "MissingApiKey": return promptForKey();
|
|
99
|
+
case "FileTooLarge": return warn(`Audio too large: ${error.sizeMb}MB`);
|
|
100
|
+
case "Unexpected": return report(error.cause);
|
|
54
101
|
}
|
|
55
102
|
}
|
|
103
|
+
showTranscript(data); // data is string, error is null
|
|
56
104
|
```
|
|
57
105
|
|
|
58
|
-
|
|
106
|
+
Three things are doing the work here, and the rest of this README is just those three things.
|
|
59
107
|
|
|
60
|
-
|
|
61
|
-
npm install wellcrafted
|
|
62
|
-
```
|
|
108
|
+
## Define your errors
|
|
63
109
|
|
|
64
|
-
|
|
110
|
+
You can put any value in `Ok` and `Err`. So why `defineErrors`?
|
|
65
111
|
|
|
66
|
-
|
|
112
|
+
Because errors aren't random. A function fails in a handful of known ways, and a namespace is where you enumerate them up front: the closed set of what can go wrong. A user service fails with `AlreadyExists`, `CreateFailed`, or `InvalidEmail`, and nothing else. That set is exactly a Rust error enum: the namespace is the enum, each key is a variant, and `switch (error.name)` is the `match`. `defineErrors` brings the [thiserror](https://docs.rs/thiserror) pattern to TypeScript as plain objects instead of classes.
|
|
67
113
|
|
|
68
|
-
|
|
114
|
+
Enumerating the set up front is what makes the rest pay off. The union flows into your `Result<T, E>` signature, so a caller sees every way the call can fail right in the type, and a `switch` with a [`never` guard](#exhaustiveness) turns a forgotten variant into a compile error.
|
|
69
115
|
|
|
70
|
-
|
|
116
|
+
Each key becomes a variant. Your constructor returns `{ message, ...fields }`; `defineErrors` stamps the key on as `name` and hands back a factory that returns `Err<...>` directly. `InferErrors` extracts the union of every variant for your `Result<T, E>` signatures.
|
|
71
117
|
|
|
72
|
-
|
|
118
|
+
```typescript
|
|
119
|
+
const UserError = defineErrors({
|
|
120
|
+
AlreadyExists: ({ email }: { email: string }) => ({
|
|
121
|
+
message: `User ${email} already exists`,
|
|
122
|
+
email,
|
|
123
|
+
}),
|
|
124
|
+
CreateFailed: ({ email, cause }: { email: string; cause: unknown }) => ({
|
|
125
|
+
message: `Failed to create user ${email}: ${extractErrorMessage(cause)}`,
|
|
126
|
+
email,
|
|
127
|
+
cause,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
type UserError = InferErrors<typeof UserError>;
|
|
131
|
+
// ^? { name: "AlreadyExists"; message: string; email: string }
|
|
132
|
+
// | { name: "CreateFailed"; message: string; email: string; cause: unknown }
|
|
133
|
+
```
|
|
73
134
|
|
|
74
|
-
|
|
135
|
+
Two properties make this pay off:
|
|
75
136
|
|
|
76
|
-
|
|
137
|
+
**Errors are data, not classes.** Plain frozen objects, no prototype chain. The fields you put on them are plain own properties, so they survive `JSON.stringify`, a Web Worker, or an IPC hop with no `stack` noise and no `instanceof` that breaks across package boundaries. The error you create is the error that arrives.
|
|
77
138
|
|
|
78
|
-
|
|
139
|
+
**Every factory returns `Err<...>` directly.** No wrapping step. Return it from a `tryAsync` catch handler or as a standalone early return (`if (existing) return UserError.AlreadyExists({ email })`). The `Result` type flows out naturally.
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
import { trySync, tryAsync } from "wellcrafted/result";
|
|
141
|
+
## Wrap throwing code
|
|
82
142
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
cause,
|
|
88
|
-
}),
|
|
89
|
-
});
|
|
143
|
+
`trySync` and `tryAsync` turn a throwing operation into a `Result`. The `catch` handler receives the raw error and returns one of your `defineErrors` variants. Anything you don't wrap keeps throwing exactly as before, so you can adopt this one function at a time.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { trySync, tryAsync, Ok } from "wellcrafted/result";
|
|
90
147
|
|
|
91
148
|
// Synchronous
|
|
92
149
|
const { data, error } = trySync({
|
|
@@ -101,135 +158,88 @@ const { data, error } = await tryAsync({
|
|
|
101
158
|
});
|
|
102
159
|
```
|
|
103
160
|
|
|
104
|
-
When `catch` returns `Ok(fallback)` instead of
|
|
161
|
+
When `catch` returns `Ok(fallback)` instead of an error, there is no error branch left: the return type narrows to `Ok<T>`, so `error` is always `null` and you can skip the check.
|
|
105
162
|
|
|
106
163
|
```typescript
|
|
107
|
-
const { data:
|
|
108
|
-
try: ():
|
|
109
|
-
catch: () => Ok([]),
|
|
164
|
+
const { data: items } = trySync({
|
|
165
|
+
try: (): string[] => JSON.parse(riskyJson),
|
|
166
|
+
catch: () => Ok([] as string[]), // recovered; there is no error to check
|
|
110
167
|
});
|
|
111
|
-
// parsed is always defined — the catch recovered
|
|
112
168
|
```
|
|
113
169
|
|
|
114
|
-
##
|
|
170
|
+
## Compose across layers
|
|
115
171
|
|
|
116
|
-
|
|
172
|
+
Each layer defines its own vocabulary and folds the layer below into a `cause` field. `extractErrorMessage` formats that cause inside the factory, so call sites stay clean. You propagate with a plain `if (error) return` (there is no `?` operator; see [what you give up](#what-you-give-up)).
|
|
117
173
|
|
|
118
174
|
```typescript
|
|
119
|
-
// Service layer: domain errors wrap raw failures via cause
|
|
120
|
-
const UserServiceError = defineErrors({
|
|
121
|
-
NotFound: ({ userId }: { userId: string }) => ({
|
|
122
|
-
message: `User ${userId} not found`,
|
|
123
|
-
userId,
|
|
124
|
-
}),
|
|
125
|
-
FetchFailed: ({ userId, cause }: { userId: string; cause: unknown }) => ({
|
|
126
|
-
message: `Failed to fetch user ${userId}: ${extractErrorMessage(cause)}`,
|
|
127
|
-
userId,
|
|
128
|
-
cause,
|
|
129
|
-
}),
|
|
130
|
-
});
|
|
131
|
-
type UserServiceError = InferErrors<typeof UserServiceError>;
|
|
132
|
-
|
|
133
175
|
async function getUser(userId: string): Promise<Result<User, UserServiceError>> {
|
|
134
|
-
const response = await tryAsync({
|
|
176
|
+
const { data: response, error } = await tryAsync({
|
|
135
177
|
try: () => fetch(`/api/users/${userId}`),
|
|
136
178
|
catch: (cause) => UserServiceError.FetchFailed({ userId, cause }),
|
|
137
|
-
//
|
|
138
|
-
});
|
|
139
|
-
if (response.error) return response;
|
|
140
|
-
|
|
141
|
-
if (response.data.status === 404) return UserServiceError.NotFound({ userId });
|
|
142
|
-
|
|
143
|
-
return tryAsync({
|
|
144
|
-
try: () => response.data.json() as Promise<User>,
|
|
145
|
-
catch: (cause) => UserServiceError.FetchFailed({ userId, cause }),
|
|
179
|
+
// raw fetch error becomes cause ^^^^^
|
|
146
180
|
});
|
|
147
|
-
|
|
181
|
+
if (error) return Err(error); // propagate as-is
|
|
148
182
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const { data, error } = await getUser(userId);
|
|
152
|
-
|
|
153
|
-
if (error) {
|
|
154
|
-
switch (error.name) {
|
|
155
|
-
case "NotFound":
|
|
156
|
-
return Response.json({ error: error.message }, { status: 404 });
|
|
157
|
-
case "FetchFailed":
|
|
158
|
-
return Response.json({ error: error.message }, { status: 502 });
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return Response.json(data);
|
|
183
|
+
if (response.status === 404) return UserServiceError.NotFound({ userId });
|
|
184
|
+
return Ok(await response.json());
|
|
163
185
|
}
|
|
164
186
|
```
|
|
165
187
|
|
|
166
|
-
|
|
188
|
+
Your tagged fields are plain data, so the chain logs and crosses the wire cleanly. The raw `cause` is the exception: it serializes only as well as whatever you caught, which is why the factories above fold it through `extractErrorMessage` into the `message` string.
|
|
167
189
|
|
|
168
190
|
## The Result type
|
|
169
191
|
|
|
170
|
-
The foundation is
|
|
192
|
+
The foundation is one discriminated union:
|
|
171
193
|
|
|
172
194
|
```typescript
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
type Ok<T> = { data: T; error: null };
|
|
176
|
-
type Err<E> = { error: E; data: null };
|
|
195
|
+
type Ok<T> = { data: T; error: null };
|
|
196
|
+
type Err<E> = { error: E; data: null };
|
|
177
197
|
type Result<T, E> = Ok<T> | Err<E>;
|
|
178
198
|
```
|
|
179
199
|
|
|
180
|
-
Check `error` first
|
|
200
|
+
Check `error` first and TypeScript narrows `data` for you:
|
|
181
201
|
|
|
182
202
|
```typescript
|
|
183
203
|
const { data, error } = await someOperation();
|
|
184
|
-
if (error)
|
|
185
|
-
// error is E, data is null
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
204
|
+
if (error) return; // error is E, data is null
|
|
188
205
|
// data is T, error is null
|
|
189
206
|
```
|
|
190
207
|
|
|
191
|
-
|
|
208
|
+
`if (error)` works because errors from `defineErrors` are always objects, and an object is truthy. The exact check is `error !== null` (or the `isErr` guard); reach for it if you ever put a falsy value like `0` or `""` in an `Err`.
|
|
192
209
|
|
|
193
|
-
###
|
|
210
|
+
### Exhaustiveness
|
|
194
211
|
|
|
195
|
-
|
|
212
|
+
`switch (error.name)` narrows each case, and your editor autocompletes every variant. To make a *new* variant a compile error until it is handled, add a `never` check in `default`:
|
|
196
213
|
|
|
197
214
|
```typescript
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const userId = "abc" as UserId;
|
|
206
|
-
const orderId = "xyz" as OrderId;
|
|
207
|
-
getUser(userId); // compiles
|
|
208
|
-
getUser(orderId); // type error
|
|
215
|
+
switch (error.name) {
|
|
216
|
+
case "NotFound": return notFound();
|
|
217
|
+
case "FetchFailed": return badGateway();
|
|
218
|
+
default:
|
|
219
|
+
error satisfies never; // add a variant and this line fails to compile
|
|
220
|
+
}
|
|
209
221
|
```
|
|
210
222
|
|
|
211
|
-
|
|
223
|
+
Plain TypeScript does not enforce exhaustive `switch` on its own; this one line is how you opt in.
|
|
212
224
|
|
|
213
|
-
|
|
225
|
+
## What you give up
|
|
214
226
|
|
|
215
|
-
|
|
216
|
-
import { createQueryFactories } from "wellcrafted/query";
|
|
227
|
+
wellcrafted is not an effect system, and the honest cost is control flow. There is no `?` operator, so you propagate with an explicit `if (error) return` at each step. There is no dependency injection, no automatic short-circuiting, and no built-in concurrency. If you need those, reach for [Effect](https://effect.website); that is what it is for.
|
|
217
228
|
|
|
218
|
-
|
|
229
|
+
In exchange, the whole API is `{ data, error }`, `async/await`, and `switch`: no new runtime, no generators, no pipe operators to learn.
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
queryKey: ["users", userId],
|
|
222
|
-
queryFn: () => getUser(userId), // returns Result<User, UserError>
|
|
223
|
-
});
|
|
231
|
+
## Also in the box
|
|
224
232
|
|
|
225
|
-
|
|
226
|
-
const query = createQuery(() => userQuery.options);
|
|
233
|
+
Each lives behind its own subpath import, so you pay for only what you use.
|
|
227
234
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
- `wellcrafted/brand`: `Brand<T>` makes distinct types from primitives (`type UserId = string & Brand<"UserId">`) so the compiler catches mix-ups. Zero runtime.
|
|
236
|
+
- `wellcrafted/logger`: a small DI-based structured logger keyed on log level, built to take your `defineErrors` types directly. No global singleton.
|
|
237
|
+
- `wellcrafted/testing`: `expectOk` / `expectErr` unwrap a `Result` in a test, or throw with a readable message.
|
|
238
|
+
- `wellcrafted/json`: `parseJson` is `JSON.parse` that returns a `Result` instead of throwing.
|
|
239
|
+
- `wellcrafted/query`: TanStack Query adapters for Result-returning functions. Queries expose `.options`, `.fetch`, and `.ensure`; mutations are callable and expose `.options`.
|
|
240
|
+
- `wellcrafted/standard-schema`: wrap a `Result` as a [Standard Schema](https://github.com/standard-schema/standard-schema) for validators that speak the spec.
|
|
231
241
|
|
|
232
|
-
##
|
|
242
|
+
## How it compares
|
|
233
243
|
|
|
234
244
|
| | wellcrafted | neverthrow | better-result | fp-ts | Effect |
|
|
235
245
|
|---|---|---|---|---|---|
|
|
@@ -239,89 +249,33 @@ const { data, error } = await userQuery.fetch();
|
|
|
239
249
|
| Bundle size | < 2KB | ~5KB | ~2KB | ~30KB | ~50KB |
|
|
240
250
|
| Syntax | async/await | Method chains | Method chains + generators | Pipe operators | Generators |
|
|
241
251
|
|
|
242
|
-
Every Result library
|
|
243
|
-
|
|
244
|
-
## Philosophy
|
|
245
|
-
|
|
246
|
-
wellcrafted is deliberately idiomatic to JavaScript. The `{ data, error }` shape isn't novel — it's the same pattern used by Supabase, SvelteKit load functions, and TanStack Query. We chose it because it's already familiar, already destructurable, and requires zero new mental models.
|
|
247
|
-
|
|
248
|
-
The same principle applies throughout: async/await instead of generators, `switch` instead of `.match()`, plain objects instead of class hierarchies. The best abstractions are the ones your team already knows. wellcrafted adds type-safe error definition on top of patterns that JavaScript developers use every day — it doesn't ask you to learn a new programming paradigm to handle errors.
|
|
249
|
-
|
|
250
|
-
## API Reference
|
|
251
|
-
|
|
252
|
-
### Error functions
|
|
253
|
-
|
|
254
|
-
- **`defineErrors(config)`** — define multiple error factories in a single call. Each key becomes a variant; the value is a factory returning `{ message, ...fields }`. Every factory returns `Err<...>` directly.
|
|
255
|
-
- **`extractErrorMessage(error)`** — extract a readable string from any `unknown` error value.
|
|
256
|
-
|
|
257
|
-
### Error types
|
|
258
|
-
|
|
259
|
-
- **`InferErrors<T>`** — extract union of all error types from a `defineErrors` return value.
|
|
260
|
-
- **`InferError<T>`** — extract a single variant's error type from one factory.
|
|
261
|
-
|
|
262
|
-
### Result functions
|
|
252
|
+
Every Result library hands you a container. wellcrafted hands you what goes inside it, then gets out of the way.
|
|
263
253
|
|
|
264
|
-
|
|
265
|
-
- **`Err(error)`** — create a failure result
|
|
266
|
-
- **`trySync({ try, catch })`** — wrap a synchronous throwing operation
|
|
267
|
-
- **`tryAsync({ try, catch })`** — wrap an async throwing operation
|
|
268
|
-
- **`isOk(result)` / `isErr(result)`** — type guards
|
|
269
|
-
- **`unwrap(result)`** — extract data or throw error
|
|
270
|
-
- **`resolve(value)`** — handle values that may or may not be Results
|
|
271
|
-
- **`partitionResults(results)`** — split an array of Results into separate ok/err arrays
|
|
254
|
+
## API at a glance
|
|
272
255
|
|
|
273
|
-
|
|
256
|
+
From `wellcrafted/result`:
|
|
274
257
|
|
|
275
|
-
-
|
|
276
|
-
-
|
|
277
|
-
-
|
|
258
|
+
- `Ok(data)` / `Err(error)`: construct a success or failure
|
|
259
|
+
- `trySync` / `tryAsync`: wrap a throwing operation, sync or async
|
|
260
|
+
- `Result<T, E>`: the `Ok<T> | Err<E>` union
|
|
278
261
|
|
|
279
|
-
|
|
262
|
+
From `wellcrafted/error`:
|
|
280
263
|
|
|
281
|
-
-
|
|
264
|
+
- `defineErrors(config)`: define a namespace of error variant factories
|
|
265
|
+
- `extractErrorMessage(value)`: pull a readable string out of any `unknown`
|
|
266
|
+
- `InferErrors<typeof MyError>`: the union of all variants; `InferError<typeof MyError.Variant>`: one variant
|
|
282
267
|
|
|
283
|
-
|
|
268
|
+
Less common but there when you need them: `isOk` / `isErr` type guards, `unwrap` (extract or throw), `partitionResults` (split an array of Results), and `resolve` (handle values that may or may not be Results).
|
|
284
269
|
|
|
285
|
-
|
|
286
|
-
- **`Brand<T, B>`** — branded type wrapper for distinct primitives
|
|
270
|
+
## Teach your AI agent
|
|
287
271
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
If you use an AI coding agent (Claude Code, Cursor, etc.), teach it how to use wellcrafted correctly:
|
|
272
|
+
If you use an AI coding agent, install the skills that teach it the patterns and anti-patterns directly:
|
|
291
273
|
|
|
292
274
|
```bash
|
|
293
275
|
npx skills add wellcrafted-dev/wellcrafted
|
|
294
276
|
```
|
|
295
277
|
|
|
296
|
-
This installs
|
|
297
|
-
|
|
298
|
-
| Skill | What it teaches |
|
|
299
|
-
| --- | --- |
|
|
300
|
-
| `define-errors` | `defineErrors` variants, `extractErrorMessage`, `InferErrors`/`InferError` type extraction |
|
|
301
|
-
| `result-types` | `Ok`, `Err`, `trySync`/`tryAsync`, the `{ data, error }` destructuring pattern |
|
|
302
|
-
| `query-factories` | `createQueryFactories`, `defineQuery`/`defineMutation`, reactive options, and imperative helpers |
|
|
303
|
-
| `branded-types` | `Brand<T>`, brand constructor pattern, when to add runtime validation |
|
|
304
|
-
| `patterns` | Architectural style guide: control flow, factory composition, service layers, error composition |
|
|
305
|
-
|
|
306
|
-
Skills work with any agent that supports [`npx skills`](https://www.npmjs.com/package/skills). Install once, update with `npx skills update`.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
## Development Setup
|
|
310
|
-
|
|
311
|
-
### AI Agent Skills
|
|
312
|
-
|
|
313
|
-
AI agent skills are managed via [`npx skills`](https://www.npmjs.com/package/skills), sourced from [Epicenter](https://github.com/EpicenterHQ/epicenter). Only skills relevant to wellcrafted's domain are installed.
|
|
314
|
-
|
|
315
|
-
```bash
|
|
316
|
-
# Install skills (already committed, but can be refreshed)
|
|
317
|
-
npx skills add EpicenterHQ/epicenter --skill error-handling --skill define-errors -a claude-code -y
|
|
318
|
-
|
|
319
|
-
# Update all installed skills
|
|
320
|
-
npx skills update
|
|
321
|
-
|
|
322
|
-
# List installed skills
|
|
323
|
-
npx skills list
|
|
324
|
-
```
|
|
278
|
+
This installs five skills: `define-errors`, `result-types`, `query-factories`, `branded-types`, and `patterns` (the architectural style guide). They work with any agent that supports [`npx skills`](https://www.npmjs.com/package/skills). Install once, update with `npx skills update`.
|
|
325
279
|
|
|
326
280
|
## License
|
|
327
281
|
|
package/dist/error/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "../result-
|
|
2
|
-
import { AnyTaggedError, DefineErrorsReturn, ErrorBody, ErrorsConfig, InferError, InferErrors, ValidatedConfig } from "../types-
|
|
3
|
-
import { defineErrors, extractErrorMessage } from "../index-
|
|
1
|
+
import "../result-_socO0Ud.js";
|
|
2
|
+
import { AnyTaggedError, DefineErrorsReturn, ErrorBody, ErrorsConfig, InferError, InferErrors, ValidatedConfig } from "../types-ojNaDB4n.js";
|
|
3
|
+
import { defineErrors, extractErrorMessage } from "../index-ByVX3bXz.js";
|
|
4
4
|
export { AnyTaggedError, DefineErrorsReturn, ErrorBody, ErrorsConfig, InferError, InferErrors, ValidatedConfig, defineErrors, extractErrorMessage };
|
package/dist/error/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Err } from "./result-
|
|
1
|
+
import { Err } from "./result-C_ph_izC.js";
|
|
2
2
|
|
|
3
3
|
//#region src/error/defineErrors.ts
|
|
4
4
|
/**
|
|
@@ -104,4 +104,4 @@ function extractErrorMessage(error) {
|
|
|
104
104
|
|
|
105
105
|
//#endregion
|
|
106
106
|
export { defineErrors, extractErrorMessage };
|
|
107
|
-
//# sourceMappingURL=error-
|
|
107
|
+
//# sourceMappingURL=error-BkeqDeUq.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-
|
|
1
|
+
{"version":3,"file":"error-BkeqDeUq.js","names":["config: TConfig & ValidatedConfig<TConfig>","result: Record<string, unknown>","error: unknown"],"sources":["../src/error/defineErrors.ts","../src/error/extractErrorMessage.ts"],"sourcesContent":["import { Err } from \"../result/result.js\";\nimport type {\n\tDefineErrorsReturn,\n\tErrorsConfig,\n\tValidatedConfig,\n} from \"./types.js\";\n\n/**\n * Defines a set of typed error factories using Rust-style namespaced variants.\n *\n * Each key is a short variant name (the namespace provides context). Every\n * factory returns `Err<...>` directly — ready for `trySync`/`tryAsync` catch\n * handlers. The variant name is stamped as `name` on the error object.\n *\n * @example\n * ```ts\n * const HttpError = defineErrors({\n * Connection: ({ cause }: { cause: unknown }) => ({\n * message: `Failed to connect: ${extractErrorMessage(cause)}`,\n * cause,\n * }),\n * Response: ({ status }: { status: number; bodyMessage?: string }) => ({\n * message: `HTTP ${status}`,\n * status,\n * }),\n * Parse: ({ cause }: { cause: unknown }) => ({\n * message: `Failed to parse response body: ${extractErrorMessage(cause)}`,\n * cause,\n * }),\n * });\n *\n * type HttpError = InferErrors<typeof HttpError>;\n *\n * const result = HttpError.Connection({ cause: 'timeout' }); // Err<...>\n * ```\n *\n * Inspired by Rust's {@link https://docs.rs/thiserror | thiserror} crate. The\n * mapping is nearly 1:1:\n *\n * - `enum HttpError` → `const HttpError = defineErrors(...)`\n * - Variant `Connection { cause: String }` → key `Connection: ({ cause }: { cause: unknown }) => (...)`\n * - `#[error(\"Failed: {cause}\")]` → `` message: `Failed: ${extractErrorMessage(cause)}` ``\n * - `HttpError::Connection { ... }` → `HttpError.Connection({ ... })`\n * - `match error { Connection { .. } => }` → `switch (error.name) { case 'Connection': }`\n *\n * The equivalent Rust `thiserror` enum:\n * ```rust\n * #[derive(Error, Debug)]\n * enum HttpError {\n * #[error(\"Failed to connect: {cause}\")]\n * Connection { cause: String },\n *\n * #[error(\"HTTP {status}\")]\n * Response { status: u16, body_message: Option<String> },\n *\n * #[error(\"Failed to parse response body: {cause}\")]\n * Parse { cause: String },\n * }\n * ```\n */\nexport function defineErrors<const TConfig extends ErrorsConfig>(\n\tconfig: TConfig & ValidatedConfig<TConfig>,\n): DefineErrorsReturn<TConfig> {\n\tconst result: Record<string, unknown> = {};\n\n\tfor (const [name, ctor] of Object.entries(config)) {\n\t\tresult[name] = (...args: unknown[]) => {\n\t\t\tconst body = (ctor as (...a: unknown[]) => Record<string, unknown>)(\n\t\t\t\t...args,\n\t\t\t);\n\t\t\treturn Err(Object.freeze({ ...body, name }));\n\t\t};\n\t}\n\n\treturn result as DefineErrorsReturn<TConfig>;\n}\n","/**\n * Extracts a readable error message from an unknown error value\n *\n * @param error - The unknown error to extract a message from\n * @returns A string representation of the error\n */\nexport function extractErrorMessage(error: unknown): string {\n\t// Handle Error instances\n\tif (error instanceof Error) {\n\t\treturn error.message;\n\t}\n\n\t// Handle primitives\n\tif (typeof error === \"string\") return error;\n\tif (\n\t\ttypeof error === \"number\" ||\n\t\ttypeof error === \"boolean\" ||\n\t\ttypeof error === \"bigint\"\n\t)\n\t\treturn String(error);\n\tif (typeof error === \"symbol\") return error.toString();\n\tif (error === null) return \"null\";\n\tif (error === undefined) return \"undefined\";\n\n\t// Handle arrays\n\tif (Array.isArray(error)) return JSON.stringify(error);\n\n\t// Handle plain objects\n\tif (typeof error === \"object\") {\n\t\tconst errorObj = error as Record<string, unknown>;\n\n\t\t// Check common error properties\n\t\tconst messageProps = [\n\t\t\t\"message\",\n\t\t\t\"error\",\n\t\t\t\"description\",\n\t\t\t\"title\",\n\t\t\t\"reason\",\n\t\t\t\"details\",\n\t\t] as const;\n\t\tfor (const prop of messageProps) {\n\t\t\tif (prop in errorObj && typeof errorObj[prop] === \"string\") {\n\t\t\t\treturn errorObj[prop];\n\t\t\t}\n\t\t}\n\n\t\t// Fallback to JSON stringification\n\t\ttry {\n\t\t\treturn JSON.stringify(error);\n\t\t} catch {\n\t\t\treturn String(error);\n\t\t}\n\t}\n\n\t// Final fallback\n\treturn String(error);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DA,SAAgB,aACfA,QAC8B;CAC9B,MAAMC,SAAkC,CAAE;AAE1C,MAAK,MAAM,CAAC,MAAM,KAAK,IAAI,OAAO,QAAQ,OAAO,CAChD,QAAO,QAAQ,CAAC,GAAG,SAAoB;EACtC,MAAM,OAAO,AAAC,KACb,GAAG,KACH;AACD,SAAO,IAAI,OAAO,OAAO;GAAE,GAAG;GAAM;EAAM,EAAC,CAAC;CAC5C;AAGF,QAAO;AACP;;;;;;;;;;ACrED,SAAgB,oBAAoBC,OAAwB;AAE3D,KAAI,iBAAiB,MACpB,QAAO,MAAM;AAId,YAAW,UAAU,SAAU,QAAO;AACtC,YACQ,UAAU,mBACV,UAAU,oBACV,UAAU,SAEjB,QAAO,OAAO,MAAM;AACrB,YAAW,UAAU,SAAU,QAAO,MAAM,UAAU;AACtD,KAAI,UAAU,KAAM,QAAO;AAC3B,KAAI,iBAAqB,QAAO;AAGhC,KAAI,MAAM,QAAQ,MAAM,CAAE,QAAO,KAAK,UAAU,MAAM;AAGtD,YAAW,UAAU,UAAU;EAC9B,MAAM,WAAW;EAGjB,MAAM,eAAe;GACpB;GACA;GACA;GACA;GACA;GACA;EACA;AACD,OAAK,MAAM,QAAQ,aAClB,KAAI,QAAQ,mBAAmB,SAAS,UAAU,SACjD,QAAO,SAAS;AAKlB,MAAI;AACH,UAAO,KAAK,UAAU,MAAM;EAC5B,QAAO;AACP,UAAO,OAAO,MAAM;EACpB;CACD;AAGD,QAAO,OAAO,MAAM;AACpB"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/function.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Run-at-most-once wrapper. Wrap `fn` so the first call invokes it and caches the result;
|
|
4
|
+
* every later call is a no-op that returns that same cached result. Arguments passed after
|
|
5
|
+
* the first call are ignored.
|
|
6
|
+
*
|
|
7
|
+
* Canonical use: an idempotent `[Symbol.dispose]` whose teardown is reachable from more than
|
|
8
|
+
* one path and must not run twice. `once` makes that guarantee declarative instead of a
|
|
9
|
+
* hand-rolled `let disposed` flag.
|
|
10
|
+
*
|
|
11
|
+
* This is for the pure "this function body runs at most once" case. A boolean that is ALSO
|
|
12
|
+
* read by other methods to short-circuit a dead object is a liveness flag, not a once-guard;
|
|
13
|
+
* keep that boolean, `once` does not replace it.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { once } from "wellcrafted/function";
|
|
18
|
+
*
|
|
19
|
+
* const init = once(() => expensiveSetup());
|
|
20
|
+
* init(); // runs expensiveSetup()
|
|
21
|
+
* init(); // returns the cached result, does not run again
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example Idempotent disposal
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { once } from "wellcrafted/function";
|
|
27
|
+
*
|
|
28
|
+
* const dispose = once(() => closeConnection());
|
|
29
|
+
* // safe to call from multiple teardown paths; closeConnection() runs at most once
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
declare function once<TArgs extends readonly unknown[], TReturn>(fn: (...args: TArgs) => TReturn): (...args: TArgs) => TReturn;
|
|
33
|
+
//# sourceMappingURL=function.d.ts.map
|
|
34
|
+
//#endregion
|
|
35
|
+
export { once };
|
|
36
|
+
//# sourceMappingURL=function.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"function.d.ts","names":[],"sources":["../src/function.ts"],"sourcesContent":[],"mappings":";;AA8BA;;;;;;AAE8B;;;;;;;;;;;;;;;;;;;;;;;iBAFd,8DACD,UAAU,oBACZ,UAAU"}
|
package/dist/function.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/function.ts
|
|
2
|
+
/**
|
|
3
|
+
* Run-at-most-once wrapper. Wrap `fn` so the first call invokes it and caches the result;
|
|
4
|
+
* every later call is a no-op that returns that same cached result. Arguments passed after
|
|
5
|
+
* the first call are ignored.
|
|
6
|
+
*
|
|
7
|
+
* Canonical use: an idempotent `[Symbol.dispose]` whose teardown is reachable from more than
|
|
8
|
+
* one path and must not run twice. `once` makes that guarantee declarative instead of a
|
|
9
|
+
* hand-rolled `let disposed` flag.
|
|
10
|
+
*
|
|
11
|
+
* This is for the pure "this function body runs at most once" case. A boolean that is ALSO
|
|
12
|
+
* read by other methods to short-circuit a dead object is a liveness flag, not a once-guard;
|
|
13
|
+
* keep that boolean, `once` does not replace it.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { once } from "wellcrafted/function";
|
|
18
|
+
*
|
|
19
|
+
* const init = once(() => expensiveSetup());
|
|
20
|
+
* init(); // runs expensiveSetup()
|
|
21
|
+
* init(); // returns the cached result, does not run again
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example Idempotent disposal
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { once } from "wellcrafted/function";
|
|
27
|
+
*
|
|
28
|
+
* const dispose = once(() => closeConnection());
|
|
29
|
+
* // safe to call from multiple teardown paths; closeConnection() runs at most once
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
function once(fn) {
|
|
33
|
+
let called = false;
|
|
34
|
+
let result;
|
|
35
|
+
return (...args) => {
|
|
36
|
+
if (!called) {
|
|
37
|
+
called = true;
|
|
38
|
+
result = fn(...args);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { once };
|
|
46
|
+
//# sourceMappingURL=function.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"function.js","names":["fn: (...args: TArgs) => TReturn","result: TReturn"],"sources":["../src/function.ts"],"sourcesContent":["/**\n * Run-at-most-once wrapper. Wrap `fn` so the first call invokes it and caches the result;\n * every later call is a no-op that returns that same cached result. Arguments passed after\n * the first call are ignored.\n *\n * Canonical use: an idempotent `[Symbol.dispose]` whose teardown is reachable from more than\n * one path and must not run twice. `once` makes that guarantee declarative instead of a\n * hand-rolled `let disposed` flag.\n *\n * This is for the pure \"this function body runs at most once\" case. A boolean that is ALSO\n * read by other methods to short-circuit a dead object is a liveness flag, not a once-guard;\n * keep that boolean, `once` does not replace it.\n *\n * @example\n * ```ts\n * import { once } from \"wellcrafted/function\";\n *\n * const init = once(() => expensiveSetup());\n * init(); // runs expensiveSetup()\n * init(); // returns the cached result, does not run again\n * ```\n *\n * @example Idempotent disposal\n * ```ts\n * import { once } from \"wellcrafted/function\";\n *\n * const dispose = once(() => closeConnection());\n * // safe to call from multiple teardown paths; closeConnection() runs at most once\n * ```\n */\nexport function once<TArgs extends readonly unknown[], TReturn>(\n\tfn: (...args: TArgs) => TReturn,\n): (...args: TArgs) => TReturn {\n\tlet called = false;\n\tlet result: TReturn;\n\treturn (...args: TArgs): TReturn => {\n\t\tif (!called) {\n\t\t\tcalled = true;\n\t\t\tresult = fn(...args);\n\t\t}\n\t\treturn result;\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,KACfA,IAC8B;CAC9B,IAAI,SAAS;CACb,IAAIC;AACJ,QAAO,CAAC,GAAG,SAAyB;AACnC,OAAK,QAAQ;AACZ,YAAS;AACT,YAAS,GAAG,GAAG,KAAK;EACpB;AACD,SAAO;CACP;AACD"}
|