@jfdevelops/create-error 0.1.1 → 0.2.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
@@ -1,294 +1,353 @@
1
- # @jfdevelops/create-error
2
-
3
- Create strongly typed, configurable error families from any synchronous
4
- [Standard Schema](https://standardschema.dev/schema) object.
5
-
6
- ## Install
7
-
8
- ```sh
9
- pnpm add @jfdevelops/create-error
10
- ```
11
-
12
- Bring your own Standard Schema-compatible validation library, such as Zod,
13
- Valibot, or ArkType. `create-error` does not bundle one.
14
-
15
- ## Quick start
16
-
17
- ```ts
18
- import { createError } from '@jfdevelops/create-error';
19
- import { z } from 'zod';
20
-
21
- const createFormError = createError({
22
- definition: z.object({
23
- code: z.string(),
24
- scope: z.string(),
25
- }),
26
- data: {
27
- property: 'context',
28
- resolve({ definition, input }) {
29
- return { ...input, scope: definition.scope };
30
- },
31
- },
32
- message({ definition, data, implementation }) {
33
- return implementation({
34
- context: data,
35
- scope: definition.scope,
36
- });
37
- },
38
- properties({ definition, data, implementation }) {
39
- return {
40
- code: definition.code,
41
- scope: definition.scope,
42
- context: data,
43
- renderMessage(renderer = implementation) {
44
- return renderer({
45
- context: data,
46
- scope: definition.scope,
47
- });
48
- },
49
- };
50
- },
51
- toJSON(error) {
52
- return {
53
- name: error.name,
54
- code: error.code,
55
- scope: error.scope,
56
- message: error.message,
57
- context: error.context,
58
- };
59
- },
60
- });
61
-
62
- export const FormError = createFormError.Error;
63
-
64
- export class InvalidFieldError extends createFormError({
65
- code: 'invalidField',
66
- scope: 'field',
67
- })
68
- .defineContext(
69
- z.object({
70
- field: z.string(),
71
- scope: z.literal('field'),
72
- }),
73
- )
74
- .implement(
75
- ({ code, context }) => `${code}: ${context.field} is invalid`,
76
- ) {}
77
-
78
- const error = new InvalidFieldError({ field: 'email' });
79
-
80
- error.code; // "invalidField"
81
- error.scope; // "field"
82
- error.context; // { field: string; scope: "field" }
83
- error instanceof Error; // true
84
- error instanceof FormError; // true
85
- ```
86
-
87
- No explicit generic arguments or `as const` assertions are needed. Definition
88
- inputs are checked by the schema, and definition-derived fields use its parsed
89
- output type so transformations remain type-safe.
90
-
91
- For reusable or separately declared configurations, the package exports a
92
- common `CreateErrorConfig` type:
93
-
94
- ```ts
95
- import { createError, type CreateErrorConfig } from '@jfdevelops/create-error';
96
-
97
- const definition = z.object({ code: z.string() });
98
-
99
- const config = {
100
- definition,
101
- data: {
102
- property: 'context',
103
- resolve: ({ input }) => input,
104
- },
105
- message: ({ data, implementation }) => implementation(data),
106
- } satisfies CreateErrorConfig<typeof definition>;
107
-
108
- const createDomainError = createError(config);
109
- ```
110
-
111
- ## How the factory works
112
-
113
- The API has four stages:
114
-
115
- 1. `createError(blueprint)` configures a family and returns a factory.
116
- 2. `factory(definition)` validates and captures one definition.
117
- 3. The returned builder optionally selects a context schema with
118
- `defineContext(schema)`.
119
- 4. `implement(callback)` captures the implementation and produces an
120
- extendable concrete error class.
121
-
122
- The definition builder exposes both `defineContext` and `implement`.
123
- `defineContext` returns a narrower builder that exposes only `implement`, so a
124
- context schema can be selected only once. Calling `implement` directly keeps
125
- context schemas optional. Every implementation receives exactly one argument;
126
- the blueprint decides its shape when it calls `implementation(argument)`. When
127
- that argument is a constructed object, the concrete definition fields are added
128
- automatically, so implementations can read values such as `code` without the
129
- blueprint repeating them. Forwarding opaque data directly preserves its original
130
- scalar, tuple, object, or class-instance shape.
131
-
132
- The `definition` option must implement Standard Schema V1 and must produce an
133
- object. Validation runs when `factory(definition)` is called. Because class
134
- creation is synchronous, a schema whose `validate` method returns a promise is
135
- rejected with a `TypeError`.
136
-
137
- ## What `data` and `properties` do
138
-
139
- `data` defines the constructor-input lifecycle. Its `resolve` callback receives
140
- the concrete definition and the value passed to `new ErrorClass(input)`. The
141
- callback can preserve that input, normalize it, or combine it with definition
142
- values. After optional `defineContext` validation, the final value is stored on
143
- the instance using `data.property`.
144
-
145
- `properties` defines the rest of the instance API. It can expose definition
146
- values such as a stable error code, derive fields from the resolved data, or add
147
- methods and getters. It does not participate in input validation or
148
- transformation.
149
-
150
- For a small error family, the constructor input can pass straight through and
151
- `properties` can be omitted entirely:
152
-
153
- ```ts
154
- const createLookupError = createError({
155
- definition: z.object({ code: z.string() }),
156
- data: {
157
- property: 'resourceId',
158
- resolve: ({ input }) => input,
159
- },
160
- message: ({ data, implementation }) => implementation(data),
161
- });
162
-
163
- class MissingUserError extends createLookupError({ code: 'missingUser' })
164
- .defineContext(z.string())
165
- .implement((resourceId) => `User ${resourceId} was not found`) {}
166
-
167
- const error = new MissingUserError('user_123');
168
- error.resourceId; // string
169
- ```
170
-
171
- Use both options when constructor input needs normalization and the error should
172
- present a richer public API:
173
-
174
- ```ts
175
- const createValidationError = createError({
176
- definition: z.object({
177
- code: z.string(),
178
- section: z.string(),
179
- }),
180
- data: {
181
- property: 'context',
182
- resolve: ({ definition, input }) => ({
183
- ...input,
184
- section: definition.section,
185
- }),
186
- },
187
- message: ({ data, implementation }) => implementation(data),
188
- properties: ({ definition, data }) => ({
189
- code: definition.code,
190
- section: definition.section,
191
- describe: () => `${definition.code} in ${data.section}`,
192
- }),
193
- });
194
-
195
- class InvalidEmailError extends createValidationError({
196
- code: 'invalidEmail',
197
- section: 'profile',
198
- })
199
- .defineContext(
200
- z.object({
201
- field: z.string(),
202
- section: z.literal('profile'),
203
- }),
204
- )
205
- .implement(({ field }) => `${field} is invalid`) {}
206
-
207
- const error = new InvalidEmailError({ field: 'email' });
208
- error.context; // { field: string; section: "profile" }
209
- error.code; // "invalidEmail"
210
- error.describe(); // "invalidEmail in profile"
211
- ```
212
-
213
- Use schema-native restrictions when definitions have a closed set of values:
214
-
215
- ```ts
216
- const createHttpError = createError({
217
- definition: z.object({
218
- code: z.enum(['notFound', 'unauthorized']),
219
- scope: z.literal('request'),
220
- }),
221
- data: {
222
- property: 'details',
223
- resolve: ({ input }) => input,
224
- },
225
- message: ({ implementation, data }) => implementation(data),
226
- });
227
-
228
- const NotFoundError = createHttpError({
229
- code: 'notFound',
230
- scope: 'request',
231
- })
232
- .defineContext(z.object({ resource: z.string() }))
233
- .implement((details) => `${details.resource} was not found`);
234
- ```
235
-
236
- ## Data and behavior
237
-
238
- `data.resolve` can return any value: objects, scalars, tuples, unions, class
239
- instances, `null`, and `undefined` are all supported. When `defineContext` is
240
- used, its Standard Schema validates the resolved data, its input type determines
241
- the concrete error constructor input, and its output type is passed to the
242
- implementation and stored on the error. Without a context schema, the last
243
- parameter of the consumer implementation determines those types.
244
-
245
- For each new error instance, callbacks run in this order:
246
-
247
- 1. `data.resolve`
248
- 2. Context validation and transformation, when configured
249
- 3. `message`
250
- 4. `properties`, when configured
251
-
252
- Exceptions thrown by a schema or callback are not wrapped. Native `Error`
253
- behavior is preserved, including stack traces, subclass names, prototypes, and
254
- the optional `cause` passed as the second constructor argument.
255
-
256
- Every family exposes its shared base as `factory.Error`. Concrete classes and
257
- named subclasses also inherit a lazy invariant helper:
258
-
259
- ```ts
260
- InvalidFieldError.invariant(
261
- formIsValid,
262
- () => ({ field: 'email' }),
263
- { cause },
264
- );
265
- ```
266
-
267
- The input function runs only when the condition is falsy.
268
-
269
- When `toJSON` is configured, it is installed once on the family prototype and
270
- used by `JSON.stringify`. Its callback can read native error fields, configured
271
- properties, and the resolved data property; each concrete error's `toJSON()`
272
- return type preserves parsed definition output and context-schema output. When
273
- omitted, the family does not add a `toJSON` method.
274
-
275
- ## Property safety
276
-
277
- `properties` may add fields, methods, getters, symbols, and other own property
278
- descriptors. It cannot replace `name`, `message`, `stack`, `cause`,
279
- `constructor`, or `prototype`. The configured data property may only be
280
- repeated with the exact resolved value. Collisions throw a `TypeError`.
281
-
282
- The library always creates the family base class. Customize a family through
283
- the blueprint instead of supplying a separate `BaseError`.
284
-
285
- ## Releasing
286
-
287
- This project uses Changesets for versioning and changelogs. Add a changeset
288
- with `pnpm changeset` whenever a package change should be released. Maintainers
289
- can consume pending changesets with `pnpm version-packages`, review the updated
290
- version and changelog, then publish with `pnpm release`.
291
-
292
- ## License
293
-
294
- MIT
1
+ # @jfdevelops/create-error
2
+
3
+ Create strongly typed, configurable error families from any synchronous
4
+ [Standard Schema](https://standardschema.dev/schema) object.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add @jfdevelops/create-error
10
+ ```
11
+
12
+ Bring your own Standard Schema-compatible validation library, such as Zod,
13
+ Valibot, or ArkType. `create-error` does not bundle one.
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { createError } from '@jfdevelops/create-error';
19
+ import { z } from 'zod';
20
+
21
+ const createFormError = createError({
22
+ definition: z.object({
23
+ code: z.string(),
24
+ scope: z.string(),
25
+ }),
26
+ data: {
27
+ property: 'context',
28
+ resolve({ definition, input }) {
29
+ return { ...input, scope: definition.scope };
30
+ },
31
+ },
32
+ message({ definition, data, implementation }) {
33
+ return implementation({
34
+ context: data,
35
+ scope: definition.scope,
36
+ });
37
+ },
38
+ properties({ definition, data, implementation }) {
39
+ return {
40
+ code: definition.code,
41
+ scope: definition.scope,
42
+ context: data,
43
+ renderMessage(renderer = implementation) {
44
+ return renderer({
45
+ context: data,
46
+ scope: definition.scope,
47
+ });
48
+ },
49
+ };
50
+ },
51
+ toJSON(error) {
52
+ return {
53
+ name: error.name,
54
+ code: error.code,
55
+ scope: error.scope,
56
+ message: error.message,
57
+ context: error.context,
58
+ };
59
+ },
60
+ });
61
+
62
+ export const FormError = createFormError.Error;
63
+
64
+ export class InvalidFieldError extends createFormError({
65
+ code: 'invalidField',
66
+ scope: 'field',
67
+ })
68
+ .defineContext(
69
+ z.object({
70
+ field: z.string(),
71
+ scope: z.literal('field'),
72
+ }),
73
+ )
74
+ .implement(
75
+ ({ code, context }) => `${code}: ${context.field} is invalid`,
76
+ ) {}
77
+
78
+ const error = new InvalidFieldError({ field: 'email' });
79
+
80
+ error.code; // "invalidField"
81
+ error.scope; // "field"
82
+ error.context; // { field: string; scope: "field" }
83
+ error instanceof Error; // true
84
+ error instanceof FormError; // true
85
+ ```
86
+
87
+ No explicit generic arguments or `as const` assertions are needed. Definition
88
+ inputs are checked by the schema, and definition-derived fields use its parsed
89
+ output type so transformations remain type-safe.
90
+
91
+ For reusable or separately declared configurations, the package exports a
92
+ common `CreateErrorConfig` type:
93
+
94
+ ```ts
95
+ import { createError, type CreateErrorConfig } from '@jfdevelops/create-error';
96
+
97
+ const definition = z.object({ code: z.string() });
98
+
99
+ const config = {
100
+ definition,
101
+ data: {
102
+ property: 'context',
103
+ resolve: ({ input }) => input,
104
+ },
105
+ message: ({ data, implementation }) => implementation(data),
106
+ } satisfies CreateErrorConfig<typeof definition>;
107
+
108
+ const createDomainError = createError(config);
109
+ ```
110
+
111
+ ## How the factory works
112
+
113
+ The API has four stages:
114
+
115
+ 1. `createError(blueprint)` configures a family and returns a factory.
116
+ 2. `factory(definition)` validates and captures one definition.
117
+ 3. The returned builder optionally selects a context schema with
118
+ `defineContext(schema)`.
119
+ 4. `implement(callback)` captures the implementation and produces an
120
+ extendable concrete error class.
121
+
122
+ The definition builder exposes both `defineContext` and `implement`.
123
+ `defineContext` returns a narrower builder that exposes only `implement`, so a
124
+ context schema can be selected only once. Calling `implement` directly keeps
125
+ context schemas optional. Every implementation receives exactly one argument;
126
+ the blueprint decides its shape when it calls `implementation(argument)`. When
127
+ that argument is a constructed object, the concrete definition fields are added
128
+ automatically, so implementations can read values such as `code` without the
129
+ blueprint repeating them. Forwarding opaque data directly preserves its original
130
+ scalar, tuple, object, or class-instance shape.
131
+
132
+ The `definition` option must implement Standard Schema V1 and must produce an
133
+ object. Validation runs when `factory(definition)` is called. Because class
134
+ creation is synchronous, a schema whose `validate` method returns a promise or
135
+ other thenable is rejected with a `TypeError`. `createError` also checks that
136
+ `data.resolve`, `message`, and any `properties` or `toJSON` options are
137
+ functions.
138
+
139
+ ## What `data` and `properties` do
140
+
141
+ `data` defines the constructor-input lifecycle. Its `resolve` callback receives
142
+ the concrete definition and the value passed to `new ErrorClass(input)`. The
143
+ callback can preserve that input, normalize it, or combine it with definition
144
+ values. After optional `defineContext` validation, the final value is stored on
145
+ the instance using `data.property`.
146
+
147
+ `properties` defines the rest of the instance API. It can expose definition
148
+ values such as a stable error code, pass the resolved data through, or add
149
+ methods and getters. It does not participate in input validation or
150
+ transformation. Because each concrete error types its data separately, the
151
+ blueprint can store `data` but cannot read its fields; derive values from
152
+ `definition` or call `implementation(data)` instead.
153
+
154
+ For a small error family, the constructor input can pass straight through and
155
+ `properties` can be omitted entirely:
156
+
157
+ ```ts
158
+ const createLookupError = createError({
159
+ definition: z.object({ code: z.string() }),
160
+ data: {
161
+ property: 'resourceId',
162
+ resolve: ({ input }) => input,
163
+ },
164
+ message: ({ data, implementation }) => implementation(data),
165
+ });
166
+
167
+ class MissingUserError extends createLookupError({ code: 'missingUser' })
168
+ .defineContext(z.string())
169
+ .implement((resourceId) => `User ${resourceId} was not found`) {}
170
+
171
+ const error = new MissingUserError('user_123');
172
+ error.resourceId; // string
173
+ ```
174
+
175
+ Use both options when constructor input needs normalization and the error should
176
+ present a richer public API:
177
+
178
+ ```ts
179
+ const createValidationError = createError({
180
+ definition: z.object({
181
+ code: z.string(),
182
+ section: z.string(),
183
+ }),
184
+ data: {
185
+ property: 'context',
186
+ resolve: ({ definition, input }) => ({
187
+ ...input,
188
+ section: definition.section,
189
+ }),
190
+ },
191
+ message: ({ data, implementation }) => implementation(data),
192
+ properties: ({ definition, data }) => ({
193
+ code: definition.code,
194
+ section: definition.section,
195
+ describe: () => `${definition.code} in ${definition.section}`,
196
+ }),
197
+ });
198
+
199
+ class InvalidEmailError extends createValidationError({
200
+ code: 'invalidEmail',
201
+ section: 'profile',
202
+ })
203
+ .defineContext(
204
+ z.object({
205
+ field: z.string(),
206
+ section: z.literal('profile'),
207
+ }),
208
+ )
209
+ .implement(({ field }) => `${field} is invalid`) {}
210
+
211
+ const error = new InvalidEmailError({ field: 'email' });
212
+ error.context; // { field: string; section: "profile" }
213
+ error.code; // "invalidEmail"
214
+ error.describe(); // "invalidEmail in profile"
215
+ ```
216
+
217
+ Use schema-native restrictions when definitions have a closed set of values:
218
+
219
+ ```ts
220
+ const createHttpError = createError({
221
+ definition: z.object({
222
+ code: z.enum(['notFound', 'unauthorized']),
223
+ scope: z.literal('request'),
224
+ }),
225
+ data: {
226
+ property: 'details',
227
+ resolve: ({ input }) => input,
228
+ },
229
+ message: ({ implementation, data }) => implementation(data),
230
+ });
231
+
232
+ const NotFoundError = createHttpError({
233
+ code: 'notFound',
234
+ scope: 'request',
235
+ })
236
+ .defineContext(z.object({ resource: z.string() }))
237
+ .implement((details) => `${details.resource} was not found`, {
238
+ name: 'NotFoundError',
239
+ });
240
+ ```
241
+
242
+ ## Naming error classes
243
+
244
+ A class declared with `class NotFoundError extends ... {}` uses its own name for
245
+ `error.name`, stack traces, and serialized output. A class assigned to a
246
+ variable has no name of its own, so pass `name` as the second argument to
247
+ `implement`. Without either, instances keep the native `"Error"` name.
248
+
249
+ ```ts
250
+ const NotFoundError = createHttpError({ code: 'notFound', scope: 'request' })
251
+ .implement((details: { resource: string }) => details.resource, {
252
+ name: 'NotFoundError',
253
+ });
254
+
255
+ new NotFoundError({ resource: 'user' }).name; // "NotFoundError"
256
+ ```
257
+
258
+ ## Data and behavior
259
+
260
+ `data.resolve` can return any value: objects, scalars, tuples, unions, class
261
+ instances, `null`, and `undefined` are all supported. When `defineContext` is
262
+ used, its Standard Schema validates the resolved data, its input type determines
263
+ the concrete error constructor input, and its output type is passed to the
264
+ implementation and stored on the error. Without a context schema, the last
265
+ parameter of the consumer implementation determines those types.
266
+
267
+ For each new error instance, callbacks run in this order:
268
+
269
+ 1. `data.resolve`
270
+ 2. Context validation and transformation, when configured
271
+ 3. `message`
272
+ 4. `properties`, when configured
273
+
274
+ Exceptions thrown by a schema or callback are not wrapped. Native `Error`
275
+ behavior is preserved, including stack traces, subclass names, prototypes, and
276
+ the optional `cause` passed as the second constructor argument.
277
+
278
+ Only plain objects passed to `implementation(argument)` receive the definition
279
+ fields. Class instances, such as a `Date` or `Map`, are passed through
280
+ unchanged. The types recognize common built-in classes, but they cannot tell
281
+ your own class instances from plain objects, so wrap those in an object, as in
282
+ `implementation({ user })`, when the implementation needs definition fields.
283
+
284
+ Every family exposes its shared base as `factory.Error`, and `factory.is(value)`
285
+ checks whether a value came from any class in the family. Both narrow to the
286
+ fields every family error shares: definition-derived properties use the
287
+ schema's output type, and the data property is `unknown`.
288
+
289
+ ```ts
290
+ try {
291
+ await saveForm();
292
+ } catch (error) {
293
+ if (createFormError.is(error)) {
294
+ error.code; // string
295
+ error.context; // unknown
296
+ }
297
+ }
298
+ ```
299
+
300
+ `is` is an `instanceof` check, so errors created by another copy of the
301
+ factory, such as one loaded in a different realm, do not match. To name the
302
+ shared instance type, use `InstanceType<typeof createFormError.Error>`.
303
+
304
+ Concrete classes and named subclasses also inherit a lazy invariant helper:
305
+
306
+ ```ts
307
+ InvalidFieldError.invariant(
308
+ formIsValid,
309
+ () => ({ field: 'email' }),
310
+ { cause },
311
+ );
312
+ ```
313
+
314
+ The input function runs only when the condition is falsy. TypeScript only
315
+ allows assertion methods on explicitly typed names, so the static method works
316
+ on `class` declarations. For a class stored in a `const`, use the standalone
317
+ `invariant` export, which narrows the same way:
318
+
319
+ ```ts
320
+ import { invariant } from '@jfdevelops/create-error';
321
+
322
+ invariant(NotFoundError, user, () => ({ resource: 'user' }));
323
+ ```
324
+
325
+ When an error's constructor input is itself a function, pass it wrapped, as in
326
+ `() => handler`, so it is not mistaken for lazy input. The types enforce this.
327
+
328
+ When `toJSON` is configured, it is installed once on the family prototype and
329
+ used by `JSON.stringify`. Its callback can read native error fields, configured
330
+ properties, and the resolved data property; each concrete error's `toJSON()`
331
+ return type preserves parsed definition output and context-schema output. When
332
+ omitted, the family does not add a `toJSON` method.
333
+
334
+ ## Property safety
335
+
336
+ `properties` may add fields, methods, getters, symbols, and other own property
337
+ descriptors. It cannot replace `name`, `message`, `stack`, `cause`,
338
+ `constructor`, or `prototype`. The configured data property may only be
339
+ repeated with the exact resolved value. Collisions throw a `TypeError`.
340
+
341
+ The library always creates the family base class. Customize a family through
342
+ the blueprint instead of supplying a separate `BaseError`.
343
+
344
+ ## Releasing
345
+
346
+ This project uses Changesets for versioning and changelogs. Add a changeset
347
+ with `pnpm changeset` whenever a package change should be released. Maintainers
348
+ can consume pending changesets with `pnpm version-packages`, review the updated
349
+ version and changelog, then publish with `pnpm release`.
350
+
351
+ ## License
352
+
353
+ MIT