wellcrafted 0.29.1 → 0.32.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 +76 -64
- package/dist/brand.d.ts +64 -5
- package/dist/brand.d.ts.map +1 -1
- package/dist/error/index.d.ts +69 -267
- package/dist/error/index.d.ts.map +1 -1
- package/dist/error/index.js +47 -87
- package/dist/error/index.js.map +1 -1
- package/dist/query/index.js +2 -2
- package/dist/result/index.js +2 -2
- package/dist/{result-DfuKgZo9.js → result-0QjbC3Hw.js} +2 -2
- package/dist/{result-DfuKgZo9.js.map → result-0QjbC3Hw.js.map} +1 -1
- package/dist/{result-B1iWFqM9.js → result-DnOm5ds5.js} +1 -3
- package/dist/result-DnOm5ds5.js.map +1 -0
- package/dist/result-DolxQXIZ.d.ts.map +1 -1
- package/dist/standard-schema/index.d.ts +371 -0
- package/dist/standard-schema/index.d.ts.map +1 -0
- package/dist/standard-schema/index.js +344 -0
- package/dist/standard-schema/index.js.map +1 -0
- package/package.json +13 -5
- package/dist/result-B1iWFqM9.js.map +0 -1
package/README.md
CHANGED
|
@@ -21,8 +21,8 @@ try {
|
|
|
21
21
|
const { data, error } = await saveUser(user);
|
|
22
22
|
if (error) {
|
|
23
23
|
switch (error.name) {
|
|
24
|
-
case "ValidationError":
|
|
25
|
-
showToast(`Invalid ${error.
|
|
24
|
+
case "ValidationError":
|
|
25
|
+
showToast(`Invalid ${error.field}`);
|
|
26
26
|
break;
|
|
27
27
|
case "AuthError":
|
|
28
28
|
redirectToLogin();
|
|
@@ -46,8 +46,8 @@ function divide(a: number, b: number): Result<number, string> {
|
|
|
46
46
|
### 🏷️ Brand Types
|
|
47
47
|
Create distinct types from primitives
|
|
48
48
|
```typescript
|
|
49
|
-
type UserId = Brand<
|
|
50
|
-
type OrderId = Brand<
|
|
49
|
+
type UserId = string & Brand<"UserId">;
|
|
50
|
+
type OrderId = string & Brand<"OrderId">;
|
|
51
51
|
|
|
52
52
|
// TypeScript prevents mixing them up!
|
|
53
53
|
function getUser(id: UserId) { /* ... */ }
|
|
@@ -58,14 +58,15 @@ Structured, serializable errors with a fluent API
|
|
|
58
58
|
```typescript
|
|
59
59
|
import { createTaggedError } from "wellcrafted/error";
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
const { ValidationError } = createTaggedError("ValidationError")
|
|
63
|
-
|
|
61
|
+
// Static error — no fields needed
|
|
62
|
+
const { ValidationError } = createTaggedError("ValidationError")
|
|
63
|
+
.withMessage(() => "Email is required");
|
|
64
|
+
ValidationError();
|
|
64
65
|
|
|
65
|
-
//
|
|
66
|
+
// Structured error — fields are spread flat on the error object
|
|
66
67
|
const { ApiError } = createTaggedError("ApiError")
|
|
67
|
-
.
|
|
68
|
-
.
|
|
68
|
+
.withFields<{ endpoint: string }>()
|
|
69
|
+
.withMessage(({ endpoint }) => `Request to ${endpoint} failed`);
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
### 🔄 Query Integration
|
|
@@ -108,22 +109,18 @@ npm install wellcrafted
|
|
|
108
109
|
|
|
109
110
|
```typescript
|
|
110
111
|
import { tryAsync } from "wellcrafted/result";
|
|
111
|
-
import { createTaggedError
|
|
112
|
+
import { createTaggedError } from "wellcrafted/error";
|
|
112
113
|
|
|
113
114
|
// Define your error with factory function
|
|
114
115
|
const { ApiError, ApiErr } = createTaggedError("ApiError")
|
|
115
|
-
.
|
|
116
|
-
.
|
|
116
|
+
.withFields<{ endpoint: string }>()
|
|
117
|
+
.withMessage(({ endpoint }) => `Failed to fetch ${endpoint}`);
|
|
117
118
|
type ApiError = ReturnType<typeof ApiError>;
|
|
118
119
|
|
|
119
120
|
// Wrap any throwing operation
|
|
120
121
|
const { data, error } = await tryAsync({
|
|
121
122
|
try: () => fetch('/api/user').then(r => r.json()),
|
|
122
|
-
catch: (e) => ApiErr({
|
|
123
|
-
message: "Failed to fetch user",
|
|
124
|
-
context: { endpoint: '/api/user' },
|
|
125
|
-
cause: { name: "FetchError", message: String(e) }
|
|
126
|
-
})
|
|
123
|
+
catch: (e) => ApiErr({ endpoint: '/api/user' })
|
|
127
124
|
});
|
|
128
125
|
|
|
129
126
|
if (error) {
|
|
@@ -216,29 +213,25 @@ if (error) {
|
|
|
216
213
|
### Wrap Unsafe Operations
|
|
217
214
|
|
|
218
215
|
```typescript
|
|
219
|
-
// Define errors with
|
|
216
|
+
// Define errors with fields and message template
|
|
220
217
|
const { ParseError, ParseErr } = createTaggedError("ParseError")
|
|
221
|
-
.
|
|
218
|
+
.withFields<{ input: string }>()
|
|
219
|
+
.withMessage(({ input }) => `Invalid JSON: ${input.slice(0, 50)}`);
|
|
222
220
|
|
|
223
221
|
const { NetworkError, NetworkErr } = createTaggedError("NetworkError")
|
|
224
|
-
.
|
|
222
|
+
.withFields<{ url: string }>()
|
|
223
|
+
.withMessage(({ url }) => `Request to ${url} failed`);
|
|
225
224
|
|
|
226
225
|
// Synchronous
|
|
227
226
|
const result = trySync({
|
|
228
227
|
try: () => JSON.parse(jsonString),
|
|
229
|
-
catch: () => ParseErr({
|
|
230
|
-
message: "Invalid JSON",
|
|
231
|
-
context: { input: jsonString }
|
|
232
|
-
})
|
|
228
|
+
catch: () => ParseErr({ input: jsonString })
|
|
233
229
|
});
|
|
234
230
|
|
|
235
231
|
// Asynchronous
|
|
236
232
|
const result = await tryAsync({
|
|
237
233
|
try: () => fetch(url),
|
|
238
|
-
catch: () => NetworkErr({
|
|
239
|
-
message: "Request failed",
|
|
240
|
-
context: { url }
|
|
241
|
-
})
|
|
234
|
+
catch: () => NetworkErr({ url })
|
|
242
235
|
});
|
|
243
236
|
```
|
|
244
237
|
|
|
@@ -250,7 +243,11 @@ import { createTaggedError } from "wellcrafted/error";
|
|
|
250
243
|
import { tryAsync, Result, Ok } from "wellcrafted/result";
|
|
251
244
|
|
|
252
245
|
const { RecorderServiceError, RecorderServiceErr } = createTaggedError("RecorderServiceError")
|
|
253
|
-
.
|
|
246
|
+
.withFields<{ currentState?: string; permissions?: string }>()
|
|
247
|
+
.withMessage(({ permissions, currentState }) => {
|
|
248
|
+
if (permissions) return `Missing ${permissions} permission`;
|
|
249
|
+
return `Invalid recorder state: ${currentState}`;
|
|
250
|
+
});
|
|
254
251
|
type RecorderServiceError = ReturnType<typeof RecorderServiceError>;
|
|
255
252
|
|
|
256
253
|
export function createRecorderService() {
|
|
@@ -260,10 +257,7 @@ export function createRecorderService() {
|
|
|
260
257
|
return {
|
|
261
258
|
async startRecording(): Promise<Result<void, RecorderServiceError>> {
|
|
262
259
|
if (isRecording) {
|
|
263
|
-
return RecorderServiceErr({
|
|
264
|
-
message: "Already recording",
|
|
265
|
-
context: { currentState: 'recording' }
|
|
266
|
-
});
|
|
260
|
+
return RecorderServiceErr({ currentState: 'recording' });
|
|
267
261
|
}
|
|
268
262
|
|
|
269
263
|
return tryAsync({
|
|
@@ -273,19 +267,13 @@ export function createRecorderService() {
|
|
|
273
267
|
// ... recording setup
|
|
274
268
|
isRecording = true;
|
|
275
269
|
},
|
|
276
|
-
catch: () => RecorderServiceErr({
|
|
277
|
-
message: "Failed to start recording",
|
|
278
|
-
context: { permissions: 'microphone' }
|
|
279
|
-
})
|
|
270
|
+
catch: () => RecorderServiceErr({ permissions: 'microphone' })
|
|
280
271
|
});
|
|
281
272
|
},
|
|
282
273
|
|
|
283
274
|
async stopRecording(): Promise<Result<Blob, RecorderServiceError>> {
|
|
284
275
|
if (!isRecording) {
|
|
285
|
-
return RecorderServiceErr({
|
|
286
|
-
message: "Not currently recording",
|
|
287
|
-
context: { currentState: 'idle' }
|
|
288
|
-
});
|
|
276
|
+
return RecorderServiceErr({ currentState: 'idle' });
|
|
289
277
|
}
|
|
290
278
|
|
|
291
279
|
// Stop recording and return blob...
|
|
@@ -379,12 +367,13 @@ const { data: parsed } = trySync({
|
|
|
379
367
|
|
|
380
368
|
### Propagation Pattern (May Fail)
|
|
381
369
|
```typescript
|
|
382
|
-
const { ParseError, ParseErr } = createTaggedError("ParseError")
|
|
370
|
+
const { ParseError, ParseErr } = createTaggedError("ParseError")
|
|
371
|
+
.withMessage(() => "Invalid JSON");
|
|
383
372
|
|
|
384
373
|
// When catch can return Err<E>, function returns Result<T, E>
|
|
385
374
|
const mayFail = trySync({
|
|
386
375
|
try: () => JSON.parse(riskyJson),
|
|
387
|
-
catch: () => ParseErr(
|
|
376
|
+
catch: () => ParseErr()
|
|
388
377
|
});
|
|
389
378
|
// mayFail: Result<object, ParseError> - Must check for errors
|
|
390
379
|
if (isOk(mayFail)) {
|
|
@@ -402,7 +391,7 @@ const smartParse = trySync({
|
|
|
402
391
|
return Ok({}); // Return Ok<T> for fallback
|
|
403
392
|
}
|
|
404
393
|
// Propagate other errors
|
|
405
|
-
return ParseErr(
|
|
394
|
+
return ParseErr();
|
|
406
395
|
}
|
|
407
396
|
});
|
|
408
397
|
// smartParse: Result<object, ParseError> - Mixed handling = Result type
|
|
@@ -435,9 +424,12 @@ Based on real-world usage, here's the recommended pattern for creating services
|
|
|
435
424
|
import { createTaggedError } from "wellcrafted/error";
|
|
436
425
|
import { Result, Ok } from "wellcrafted/result";
|
|
437
426
|
|
|
438
|
-
// 1. Define service-specific errors with typed
|
|
427
|
+
// 1. Define service-specific errors with typed fields and message
|
|
439
428
|
const { RecorderServiceError, RecorderServiceErr } = createTaggedError("RecorderServiceError")
|
|
440
|
-
.
|
|
429
|
+
.withFields<{ isRecording: boolean }>()
|
|
430
|
+
.withMessage(({ isRecording }) =>
|
|
431
|
+
isRecording ? "Already recording" : "Not currently recording"
|
|
432
|
+
);
|
|
441
433
|
type RecorderServiceError = ReturnType<typeof RecorderServiceError>;
|
|
442
434
|
|
|
443
435
|
// 2. Create service with factory function
|
|
@@ -449,10 +441,7 @@ export function createRecorderService() {
|
|
|
449
441
|
return {
|
|
450
442
|
startRecording(): Result<void, RecorderServiceError> {
|
|
451
443
|
if (isRecording) {
|
|
452
|
-
return RecorderServiceErr({
|
|
453
|
-
message: "Already recording",
|
|
454
|
-
context: { isRecording }
|
|
455
|
-
});
|
|
444
|
+
return RecorderServiceErr({ isRecording });
|
|
456
445
|
}
|
|
457
446
|
|
|
458
447
|
isRecording = true;
|
|
@@ -461,10 +450,7 @@ export function createRecorderService() {
|
|
|
461
450
|
|
|
462
451
|
stopRecording(): Result<Blob, RecorderServiceError> {
|
|
463
452
|
if (!isRecording) {
|
|
464
|
-
return RecorderServiceErr({
|
|
465
|
-
message: "Not currently recording",
|
|
466
|
-
context: { isRecording }
|
|
467
|
-
});
|
|
453
|
+
return RecorderServiceErr({ isRecording });
|
|
468
454
|
}
|
|
469
455
|
|
|
470
456
|
isRecording = false;
|
|
@@ -549,7 +535,11 @@ export async function GET(request: Request) {
|
|
|
549
535
|
|
|
550
536
|
```typescript
|
|
551
537
|
const { FormError, FormErr } = createTaggedError("FormError")
|
|
552
|
-
.
|
|
538
|
+
.withFields<{ fields: Record<string, string[]> }>()
|
|
539
|
+
.withMessage(({ fields }) => {
|
|
540
|
+
const fieldNames = Object.keys(fields).join(", ");
|
|
541
|
+
return `Validation failed for: ${fieldNames}`;
|
|
542
|
+
});
|
|
553
543
|
|
|
554
544
|
function validateLoginForm(data: unknown): Result<LoginData, FormError> {
|
|
555
545
|
const errors: Record<string, string[]> = {};
|
|
@@ -559,10 +549,7 @@ function validateLoginForm(data: unknown): Result<LoginData, FormError> {
|
|
|
559
549
|
}
|
|
560
550
|
|
|
561
551
|
if (Object.keys(errors).length > 0) {
|
|
562
|
-
return FormErr({
|
|
563
|
-
message: "Validation failed",
|
|
564
|
-
context: { fields: errors }
|
|
565
|
-
});
|
|
552
|
+
return FormErr({ fields: errors });
|
|
566
553
|
}
|
|
567
554
|
|
|
568
555
|
return Ok(data as LoginData);
|
|
@@ -632,10 +619,11 @@ For comprehensive examples, service layer patterns, framework integrations, and
|
|
|
632
619
|
### Error Functions
|
|
633
620
|
- **`createTaggedError(name)`** - Creates error factory functions with fluent API
|
|
634
621
|
- Returns `{ErrorName}` (plain error) and `{ErrorName}Err` (Err-wrapped)
|
|
635
|
-
-
|
|
636
|
-
- Chain `.
|
|
637
|
-
-
|
|
638
|
-
-
|
|
622
|
+
- Chain `.withFields<T>()` to add typed fields (spread flat on the error object)
|
|
623
|
+
- Chain `.withMessage(fn)` *(optional)* to seal the message with a template — `message` is not in the factory input type when present
|
|
624
|
+
- Without `.withMessage()`, `message` is required at the call site
|
|
625
|
+
- `name` is a reserved key — prevented at compile time by `NoReservedKeys`
|
|
626
|
+
- Factory input is flat (e.g., `{ endpoint: '/api' }`), not nested
|
|
639
627
|
- **`extractErrorMessage(error)`** - Extract readable message from unknown error
|
|
640
628
|
|
|
641
629
|
### Types
|
|
@@ -649,6 +637,30 @@ For comprehensive examples, service layer patterns, framework integrations, and
|
|
|
649
637
|
- **`UnwrapOk<R>`** - Extract success value type from Result
|
|
650
638
|
- **`UnwrapErr<R>`** - Extract error value type from Result
|
|
651
639
|
|
|
640
|
+
## Development Setup
|
|
641
|
+
|
|
642
|
+
### Peer Directory Requirement
|
|
643
|
+
|
|
644
|
+
Wellcrafted shares AI agent skills (`.agents/skills/`, `.claude/skills/`) with the [Epicenter](https://github.com/EpicenterHQ/epicenter) repo via relative symlinks. Epicenter is the source of truth for skill definitions — they're authored and maintained there, and wellcrafted consumes them to stay in sync.
|
|
645
|
+
|
|
646
|
+
**Both repos must be sibling directories under the same parent:**
|
|
647
|
+
|
|
648
|
+
```
|
|
649
|
+
Code/
|
|
650
|
+
├── epicenter/ # Source of truth for skills
|
|
651
|
+
│ └── .agents/skills/
|
|
652
|
+
└── wellcrafted/ # Symlinks to epicenter
|
|
653
|
+
├── .agents/skills/<name> → ../../../epicenter/.agents/skills/<name>
|
|
654
|
+
└── .claude/skills/<name> → ../../.agents/skills/<name>
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
If symlinks appear broken after cloning, ensure epicenter is cloned alongside wellcrafted:
|
|
658
|
+
|
|
659
|
+
```bash
|
|
660
|
+
cd "$(git rev-parse --show-toplevel)/.."
|
|
661
|
+
git clone https://github.com/EpicenterHQ/epicenter.git
|
|
662
|
+
```
|
|
663
|
+
|
|
652
664
|
## License
|
|
653
665
|
|
|
654
666
|
MIT
|
package/dist/brand.d.ts
CHANGED
|
@@ -6,24 +6,41 @@ declare const brand: unique symbol;
|
|
|
6
6
|
/**
|
|
7
7
|
* Creates a brand type for nominal typing in TypeScript.
|
|
8
8
|
*
|
|
9
|
-
* Branded types
|
|
9
|
+
* Branded types create distinct types from primitive types, preventing
|
|
10
10
|
* accidental mixing of values that should be semantically different.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* ## Why wellcrafted Brand?
|
|
13
|
+
*
|
|
14
|
+
* **Composable** — Brands stack to create hierarchical type relationships.
|
|
15
|
+
* Child types are assignable to parent types, but not vice versa. Multiple
|
|
16
|
+
* inheritance works via intersection. This is possible because of the nested
|
|
17
|
+
* object structure `{ [brand]: { [K in T]: true } }` — when brands intersect,
|
|
18
|
+
* their properties merge instead of conflicting.
|
|
19
|
+
*
|
|
20
|
+
* **Framework-agnostic** — Unlike Zod's `.brand()`, ArkType's `.brand()`, or
|
|
21
|
+
* Valibot's `v.brand()` — which each produce library-specific branded types —
|
|
22
|
+
* wellcrafted's `Brand<T>` is a pure type utility with zero runtime footprint.
|
|
23
|
+
* Define your branded type once, then plug it into any runtime validator.
|
|
24
|
+
* Switch validation libraries without touching your type definitions.
|
|
25
|
+
*
|
|
26
|
+
* **Dual-declaration friendly** — TypeScript has two parallel namespaces: types
|
|
27
|
+
* and values. You can use the same PascalCase name for both the branded type and
|
|
28
|
+
* its runtime validator. JSDoc written on the type shows up everywhere the name
|
|
29
|
+
* appears — function signatures, schema definitions, and imports — giving you a
|
|
30
|
+
* single hover experience across your entire codebase.
|
|
13
31
|
*
|
|
14
32
|
* @template T - A string literal type that serves as the brand identifier
|
|
15
33
|
*
|
|
16
|
-
* @example Single brand
|
|
34
|
+
* @example Single brand — preventing ID mix-ups
|
|
17
35
|
* ```typescript
|
|
18
36
|
* type UserId = string & Brand<"UserId">;
|
|
19
37
|
* type OrderId = string & Brand<"OrderId">;
|
|
20
38
|
*
|
|
21
|
-
* // UserId and OrderId are incompatible
|
|
22
39
|
* const userId: UserId = "user-123" as UserId;
|
|
23
40
|
* const orderId: OrderId = userId; // ❌ Type error
|
|
24
41
|
* ```
|
|
25
42
|
*
|
|
26
|
-
* @example Hierarchical brands
|
|
43
|
+
* @example Hierarchical brands — child assignable to parent
|
|
27
44
|
* ```typescript
|
|
28
45
|
* type AbsolutePath = string & Brand<"AbsolutePath">;
|
|
29
46
|
* type ConfigPath = AbsolutePath & Brand<"ConfigPath">;
|
|
@@ -41,6 +58,48 @@ declare const brand: unique symbol;
|
|
|
41
58
|
*
|
|
42
59
|
* // SafeData is assignable to both Serializable and Validated
|
|
43
60
|
* ```
|
|
61
|
+
*
|
|
62
|
+
* @example Dual-declaration — same name for type and runtime validator
|
|
63
|
+
*
|
|
64
|
+
* TypeScript resolves the name from context: type position = branded type,
|
|
65
|
+
* value position = runtime validator. One name, zero ambiguity.
|
|
66
|
+
*
|
|
67
|
+
* ```typescript
|
|
68
|
+
* // Type-only brand (no runtime validation needed)
|
|
69
|
+
* type Guid = string & Brand<"Guid">;
|
|
70
|
+
*
|
|
71
|
+
* // Dual-declaration: type + runtime validator share the same name.
|
|
72
|
+
* // Hover over FileId anywhere — IDE shows the same JSDoc whether
|
|
73
|
+
* // it appears as a type annotation or a runtime schema.
|
|
74
|
+
* type FileId = Guid & Brand<"FileId">;
|
|
75
|
+
* const FileId = type("string").pipe((s): FileId => s as FileId);
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @example Framework-agnostic — same Brand, any validator
|
|
79
|
+
*
|
|
80
|
+
* Define the type once with `Brand<T>`, then create runtime validators
|
|
81
|
+
* with whichever library you prefer. Your branded types are never locked
|
|
82
|
+
* to a specific validation library.
|
|
83
|
+
*
|
|
84
|
+
* ```typescript
|
|
85
|
+
* import { type } from "arktype";
|
|
86
|
+
* import { z } from "zod";
|
|
87
|
+
* import * as v from "valibot";
|
|
88
|
+
*
|
|
89
|
+
* // Define the type ONCE — it's just a type, no library dependency
|
|
90
|
+
* type FileId = string & Brand<"FileId">;
|
|
91
|
+
*
|
|
92
|
+
* // ArkType
|
|
93
|
+
* const FileId = type("string").pipe((s): FileId => s as FileId);
|
|
94
|
+
*
|
|
95
|
+
* // Zod
|
|
96
|
+
* const FileId = z.string().transform((s): FileId => s as FileId);
|
|
97
|
+
*
|
|
98
|
+
* // Valibot
|
|
99
|
+
* const FileId = v.pipe(v.string(), v.transform((s): FileId => s as FileId));
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @see {@link https://wellcrafted.dev/integrations/validation-libraries | Using Brand with Validation Libraries}
|
|
44
103
|
*/
|
|
45
104
|
type Brand<T extends string> = {
|
|
46
105
|
[brand]: { [K in T]: true };
|
package/dist/brand.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"brand.d.ts","names":[],"sources":["../src/brand.ts"],"sourcesContent":[],"mappings":";;;AAGkC;cAApB,
|
|
1
|
+
{"version":3,"file":"brand.d.ts","names":[],"sources":["../src/brand.ts"],"sourcesContent":[],"mappings":";;;AAGkC;cAApB,KAoGJ,EAAA,OAAA,MAAA;;;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KADF;GACH,KAAA,WAAgB"}
|