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 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.context.field}`);
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<string, "UserId">;
50
- type OrderId = Brand<string, "OrderId">;
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
- // Minimal by default - only name and message
62
- const { ValidationError } = createTaggedError("ValidationError");
63
- ValidationError({ message: "Email is required" });
61
+ // Static error no fields needed
62
+ const { ValidationError } = createTaggedError("ValidationError")
63
+ .withMessage(() => "Email is required");
64
+ ValidationError();
64
65
 
65
- // Chain to add context and cause when needed
66
+ // Structured error fields are spread flat on the error object
66
67
  const { ApiError } = createTaggedError("ApiError")
67
- .withContext<{ endpoint: string }>()
68
- .withCause<NetworkError | undefined>();
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, type AnyTaggedError } from "wellcrafted/error";
112
+ import { createTaggedError } from "wellcrafted/error";
112
113
 
113
114
  // Define your error with factory function
114
115
  const { ApiError, ApiErr } = createTaggedError("ApiError")
115
- .withContext<{ endpoint: string }>()
116
- .withCause<AnyTaggedError | undefined>();
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 context and cause
216
+ // Define errors with fields and message template
220
217
  const { ParseError, ParseErr } = createTaggedError("ParseError")
221
- .withContext<{ input: string }>();
218
+ .withFields<{ input: string }>()
219
+ .withMessage(({ input }) => `Invalid JSON: ${input.slice(0, 50)}`);
222
220
 
223
221
  const { NetworkError, NetworkErr } = createTaggedError("NetworkError")
224
- .withContext<{ url: string }>();
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
- .withContext<{ currentState?: string; permissions?: string }>();
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({ message: "Invalid JSON" })
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({ message: "Parse failed" });
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 context
427
+ // 1. Define service-specific errors with typed fields and message
439
428
  const { RecorderServiceError, RecorderServiceErr } = createTaggedError("RecorderServiceError")
440
- .withContext<{ isRecording: boolean }>();
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
- .withContext<{ fields: Record<string, string[]> }>();
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
- - **Default**: minimal errors with only `name` and `message`
636
- - Chain `.withContext<T>()` to add typed context
637
- - Chain `.withCause<T>()` to add typed cause
638
- - Include `| undefined` in type to make property optional but typed
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 help create distinct types from primitive types, preventing
9
+ * Branded types create distinct types from primitive types, preventing
10
10
  * accidental mixing of values that should be semantically different.
11
11
  *
12
- * Brands can be stacked to create hierarchical type relationships:
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 };
@@ -1 +1 @@
1
- {"version":3,"file":"brand.d.ts","names":[],"sources":["../src/brand.ts"],"sourcesContent":[],"mappings":";;;AAGkC;cAApB,KAyCJ,EAAA,OAAA,MAAA;;;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KADF;GACH,KAAA,WAAgB"}
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"}