@pinesw/rttg 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/README.md +770 -2
  2. package/package.json +7 -3
package/README.md CHANGED
@@ -1,5 +1,773 @@
1
- Compile:
1
+ # rttg — Run-Time Type Guards
2
+
3
+ Build a validator out of ordinary function calls, and let TypeScript infer the type it guards:
4
+
5
+ ```ts
6
+ import { object, number, string } from "@pinesw/rttg";
7
+
8
+ const commentSchema = object({ id: number(), body: string() });
9
+ // commentSchema: Schema<{ id: number; body: string }>
10
+ ```
11
+
12
+ `commentSchema.validate(x)` is a type guard, so inside the `if` the compiler knows `x` has
13
+ `id: number` and `body: string`. Annotate the schema with the type you already have
14
+ (`const commentSchema: Schema<Comment> = object({ ... })`) and the compiler will *also* reject a
15
+ schema that has drifted away from `Comment` — wrong property type, missing property, or
16
+ superfluous property.
17
+
18
+ Two things make this cheap to adopt: **zero runtime dependencies**, and **no compile-time
19
+ transformer**. Unlike [typia](https://typia.io/), which derives validators from your types and
20
+ therefore needs its own compiler plugin wired into your build, rttg is a plain library — a schema
21
+ is a value you construct at runtime, and `tsc` is the only tool involved. The price is that you
22
+ write the schema by hand; the annotation is what stops it from rotting.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @pinesw/rttg
28
+ ```
29
+
30
+ The package ships the TypeScript source (`index.ts`) alongside the compiled `index.js` and
31
+ `index.d.ts`, so it works whether your bundler consumes source or your runtime consumes the
32
+ emitted JavaScript.
33
+
34
+ **TypeScript with `strict` enabled is required for the compile-time guarantees.** Specifically,
35
+ the superfluous-property check relies on `strictFunctionTypes` (included in `strict`); with
36
+ `strictFunctionTypes: false` a schema carrying extra properties is accepted silently. The
37
+ library's runtime behaviour does not depend on your compiler settings.
38
+
39
+ ## Quick start
40
+
41
+ ```ts
42
+ import { Schema, boolean, number, object, string } from "@pinesw/rttg";
43
+
44
+ interface Comment {
45
+ id: number;
46
+ body: string;
47
+ pinned: boolean;
48
+ }
49
+
50
+ const commentSchema: Schema<Comment> = object({
51
+ id: number(),
52
+ body: string(),
53
+ pinned: boolean(),
54
+ });
55
+
56
+ function handle(payload: unknown) {
57
+ if (commentSchema.validate(payload)) {
58
+ // payload is Comment here
59
+ console.log(payload.body.toUpperCase());
60
+ }
61
+ }
62
+ ```
63
+
64
+ Three facts about that snippet:
65
+
66
+ - `object({ ... })` is a normal function call evaluated at runtime. There is no code generation.
67
+ - `validate` is declared `value is T`, so TypeScript narrows `payload` for you.
68
+ - `Schema<Comment>` is not decoration. It is the cross-check described next.
69
+
70
+ ## Keeping schemas in sync with your types
71
+
72
+ This is the core idiom of the library:
73
+
74
+ ```ts
75
+ const commentSchema: Schema<Comment> = object({ /* ... */ });
76
+ ```
77
+
78
+ Without the annotation you still get a working guard, but nothing ties it to `Comment`:
79
+
80
+ ```ts
81
+ // Inference only. Compiles fine, even though it no longer describes Comment.
82
+ const commentSchema = object({ id: string(), body: string() });
83
+ // => Schema<{ id: string; body: string }>
84
+ ```
85
+
86
+ The compiler has no opinion, because you never told it what the schema was *supposed* to be. Add
87
+ the annotation and all three ways a schema can drift become compile errors. Each error below is
88
+ real `tsc --noEmit --strict` output, with the long file path trimmed.
89
+
90
+ ### Wrong property type
91
+
92
+ ```ts
93
+ interface Comment {
94
+ id: number;
95
+ body: string;
96
+ pinned: boolean;
97
+ }
98
+
99
+ const commentSchema: Schema<Comment> = object({
100
+ id: string(), // should be number()
101
+ body: string(),
102
+ pinned: string(), // should be boolean()
103
+ });
104
+ ```
105
+
106
+ ```
107
+ comment-schema.ts(9,7): error TS2322: Type 'Schema<{ id: string; body: string; pinned: string; }>' is not assignable to type 'Schema<Comment>'.
108
+ Types of property 'validate' are incompatible.
109
+ Type '(value: unknown) => value is { id: string; body: string; pinned: string; }' is not assignable to type '(value: unknown) => value is Comment'.
110
+ Type predicate 'value is { id: string; body: string; pinned: string; }' is not assignable to 'value is Comment'.
111
+ Type '{ id: string; body: string; pinned: string; }' is not assignable to type 'Comment'.
112
+ Types of property 'id' are incompatible.
113
+ Type 'string' is not assignable to type 'number'.
114
+ ```
115
+
116
+ ### Missing property
117
+
118
+ ```ts
119
+ const commentSchema: Schema<Comment> = object({
120
+ id: number(),
121
+ body: string(),
122
+ // pinned is gone
123
+ });
124
+ ```
125
+
126
+ ```
127
+ comment-schema.ts(9,7): error TS2322: Type 'Schema<{ id: number; body: string; }>' is not assignable to type 'Schema<Comment>'.
128
+ Types of property 'validate' are incompatible.
129
+ Type '(value: unknown) => value is { id: number; body: string; }' is not assignable to type '(value: unknown) => value is Comment'.
130
+ Type predicate 'value is { id: number; body: string; }' is not assignable to 'value is Comment'.
131
+ Property 'pinned' is missing in type '{ id: number; body: string; }' but required in type 'Comment'.
132
+ ```
133
+
134
+ ### Superfluous property
135
+
136
+ ```ts
137
+ const commentSchema: Schema<Comment> = object({
138
+ id: number(),
139
+ body: string(),
140
+ pinned: boolean(),
141
+ authorId: number(), // not on Comment
142
+ });
143
+ ```
144
+
145
+ ```
146
+ comment-schema.ts(9,7): error TS2322: Type 'Schema<{ id: number; body: string; pinned: boolean; authorId: number; }>' is not assignable to type 'Schema<Comment>'.
147
+ Types of property 'bivarianceHack' are incompatible.
148
+ Type '((arg: { id: number; body: string; pinned: boolean; authorId: number; }) => void) | undefined' is not assignable to type '((arg: Comment) => void) | undefined'.
149
+ Type '(arg: { id: number; body: string; pinned: boolean; authorId: number; }) => void' is not assignable to type '(arg: Comment) => void'.
150
+ Types of parameters 'arg' and 'arg' are incompatible.
151
+ Property 'authorId' is missing in type 'Comment' but required in type '{ id: number; body: string; pinned: boolean; authorId: number; }'.
152
+ ```
153
+
154
+ ### Why the third case needs `bivarianceHack`
155
+
156
+ Notice which property each error blames. The first two are caught by `validate`, whose
157
+ `value is T` return position makes `Schema<T>` **covariant** in `T`: a schema for a *narrower*
158
+ type is assignable, a schema missing a property is not. Covariance alone would happily accept a
159
+ schema for `{ id, body, pinned, authorId }` where `Schema<Comment>` is expected, because that
160
+ object type is a subtype of `Comment`.
161
+
162
+ So `Schema<T>` carries one extra, never-called property:
163
+
164
+ ```ts
165
+ bivarianceHack?: (arg: T) => void;
166
+ ```
167
+
168
+ As a *property* holding a function (not a method), `strictFunctionTypes` checks it
169
+ contravariantly, which makes `Schema<T>` **contravariant** in `T` as well. Covariant plus
170
+ contravariant means invariant: `T` has to match in both directions, and the extra `authorId` is
171
+ reported. You never read or write this property — it exists purely to pin the variance.
172
+
173
+ ## The three ways to use a schema
174
+
175
+ | Call | Returns | On mismatch | Use when |
176
+ | --- | --- | --- | --- |
177
+ | `schema.validate(value)` | `boolean` (`value is T`) | returns `false` | you want to branch |
178
+ | `schema.assertType(value)` | `void` (`asserts value is T`) | throws `Error` | the value is already in a variable you want narrowed |
179
+ | `schema.as(value)` | `T` | throws `Error` | you want an expression of type `T` |
180
+
181
+ ```ts
182
+ import { Schema, number, object, string } from "@pinesw/rttg";
183
+
184
+ interface User {
185
+ id: number;
186
+ name: string;
187
+ }
188
+
189
+ const userSchema: Schema<User> = object({ id: number(), name: string() });
190
+
191
+ declare const input: unknown;
192
+
193
+ // 1. branch
194
+ if (userSchema.validate(input)) {
195
+ console.log(input.name);
196
+ }
197
+
198
+ // 2. assert, then keep using the same variable
199
+ userSchema.assertType(input);
200
+ console.log(input.name);
201
+
202
+ // 3. convert in an expression
203
+ const user = userSchema.as(input);
204
+ console.log(user.name);
205
+ ```
206
+
207
+ `as` is a checked cast, not a parser: it returns **the very same object reference** it was given,
208
+ with no copying, stripping of extra keys, or coercion.
209
+
210
+ ```ts
211
+ const raw = { id: 1, name: "ada" };
212
+ userSchema.as(raw) === raw; // true
213
+ ```
214
+
215
+ ### `assertType` has two syntactic requirements
216
+
217
+ `assertType` is an `asserts` function, and TypeScript puts two restrictions on how those may be
218
+ called. Both bite in practice, and neither is rttg's doing.
219
+
220
+ First, the schema variable **must carry an explicit type annotation** — which is exactly the
221
+ annotation this library wants you to write anyway, but an inferred schema will not do:
222
+
223
+ ```ts
224
+ const inferred = object({ id: number() }); // no `: Schema<...>`
225
+ inferred.assertType(input);
226
+ ```
227
+
228
+ ```
229
+ assert-calls.ts(7,1): error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.
230
+ ```
231
+
232
+ Second, the call target must be a plain name — you **cannot** call `.assertType` on the result of
233
+ an expression:
234
+
235
+ ```ts
236
+ string().assertType(input);
237
+ ```
238
+
239
+ ```
240
+ assert-calls.ts(10,1): error TS2776: Assertions require the call target to be an identifier or qualified name.
241
+ ```
242
+
243
+ Neither restriction applies to `validate` or `as`, so `string().validate(x)` and
244
+ `string().as(x)` are both fine.
245
+
246
+ ### The standalone `assertType`
247
+
248
+ `assertType(value, schema)` takes the schema as an argument, which sidesteps the second
249
+ restriction above: the call target is the imported `assertType`, so the schema can be any
250
+ expression. The trade-off is a generic error message instead of the schema's own:
251
+
252
+ ```ts
253
+ import { Schema, assertType, string } from "@pinesw/rttg";
254
+
255
+ declare const value: unknown;
256
+
257
+ const nameSchema: Schema<string> = string();
258
+
259
+ assertType(value, nameSchema); // throws: Type assertion failed
260
+ nameSchema.assertType(value); // throws: Expected string
261
+ ```
262
+
263
+ ## API reference
264
+
265
+ Every export of the package. The error strings below are exactly what `assertType` and `as` throw.
266
+
267
+ ### `Schema<T>`
268
+
269
+ The interface every schema implements.
270
+
271
+ ```ts
272
+ interface Schema<T> {
273
+ validate(value: unknown): value is T;
274
+ assertType(value: unknown): asserts value is T;
275
+ as(value: unknown): T;
276
+ bivarianceHack?: (arg: T) => void;
277
+ }
278
+ ```
279
+
280
+ Use it as an annotation (`const s: Schema<Comment> = ...`) and as a parameter type when you write
281
+ functions that take schemas. `bivarianceHack` is explained
282
+ [above](#why-the-third-case-needs-bivariancehack); ignore it otherwise.
283
+
284
+ ### `of<T>(value: T): Schema<T>`
285
+
286
+ Matches one specific value, compared with `===`.
287
+
288
+ ```ts
289
+ of(42).validate(42); // true
290
+ of(42).validate("42"); // false
291
+ ```
292
+
293
+ Error: `Expected <value>`, built by interpolating the value — so `of(1).as(2)` throws
294
+ `Expected 1`.
295
+
296
+ ### `instanceOf<T>(constructor: new (...args: any[]) => T): Schema<T>`
297
+
298
+ Matches values satisfying `value instanceof constructor`.
299
+
300
+ ```ts
301
+ instanceOf(Date).validate(new Date()); // true
302
+ instanceOf(Date).validate("2020"); // false
303
+ ```
304
+
305
+ Error: `Expected instance of <constructor.name>` — e.g. `Expected instance of Date`.
306
+
307
+ ### `number(): Schema<number>`
308
+
309
+ `typeof value === "number"`.
310
+
311
+ ```ts
312
+ number().validate(1); // true
313
+ number().validate("1"); // false
314
+ ```
315
+
316
+ Error: `Expected number`.
317
+
318
+ ### `string(): Schema<string>`
319
+
320
+ `typeof value === "string"`.
321
+
322
+ ```ts
323
+ string().validate("a"); // true
324
+ string().validate(1); // false
325
+ ```
326
+
327
+ Error: `Expected string`.
328
+
329
+ ### `boolean(): Schema<boolean>`
330
+
331
+ `typeof value === "boolean"`.
332
+
333
+ ```ts
334
+ boolean().validate(false); // true
335
+ boolean().validate(0); // false
336
+ ```
337
+
338
+ Error: `Expected boolean`.
339
+
340
+ ### `object<T extends {}>(schema: { [K in keyof T]: Schema<T[K]> }): Schema<T>`
341
+
342
+ Matches a non-null object that has every listed **string** key (checked with `in`) with a matching
343
+ value. Keys you did not list are ignored at runtime, and symbol keys *in the schema* are never
344
+ checked at all — see [`object()` never checks symbol keys](#object-never-checks-symbol-keys--bug).
345
+
346
+ ```ts
347
+ const point = object({ x: number(), y: number() });
348
+ point.validate({ x: 1, y: 2 }); // true
349
+ point.validate({ x: 1, y: 2, z: 3 }); // true — extra keys ignored
350
+ point.validate({ x: 1 }); // false
351
+ ```
352
+
353
+ Error: `Object validation failed`.
354
+
355
+ ### `array<T>(schema: Schema<T>): Schema<T[]>`
356
+
357
+ `Array.isArray(value)` and every element matches.
358
+
359
+ ```ts
360
+ array(number()).validate([1, 2, 3]); // true
361
+ array(number()).validate([1, "2"]); // false
362
+ array(string()).validate("ab"); // false — a string is not an array
363
+ ```
364
+
365
+ Error: `Array validation failed`.
366
+
367
+ ### `tuple<T extends unknown[]>(...schemas: { [K in keyof T]: Schema<T[K]> }): Schema<T>`
368
+
369
+ An array of exactly `schemas.length` elements, each matching the schema at its position.
370
+
371
+ ```ts
372
+ const pair = tuple(string(), number());
373
+ pair.validate(["a", 1]); // true
374
+ pair.validate(["a", 1, 2]); // false — wrong length
375
+ pair.validate(["a"]); // false
376
+ ```
377
+
378
+ Error: `Tuple validation failed`.
379
+
380
+ ### `union<T extends unknown[]>(...schemas: { [K in keyof T]: Schema<T[K]> }): Schema<T[number]>`
381
+
382
+ Matches if **at least one** schema matches.
383
+
384
+ ```ts
385
+ union(string(), number()).validate("a"); // true
386
+ union(string(), number()).validate(1); // true
387
+ union(string(), number()).validate(true); // false
388
+ ```
389
+
390
+ Error: `Union validation failed`.
391
+
392
+ ### `intersection<T extends unknown[]>(...schemas: { [K in keyof T]: Schema<T[K]> }): Schema<A & B & ...>`
393
+
394
+ Matches if **every** schema matches. The resulting type is the intersection of the element types.
395
+
396
+ ```ts
397
+ const identified = intersection(object({ id: number() }), object({ body: string() }));
398
+ // Schema<{ id: number; } & { body: string; }>
399
+ identified.validate({ id: 1, body: "x" }); // true
400
+ identified.validate({ id: 1 }); // false
401
+ ```
402
+
403
+ Error: `Intersection validation failed`.
404
+
405
+ ### `assertType<T>(value: unknown, schema: Schema<T>): asserts value is T`
406
+
407
+ Standalone assertion, for passing the schema as an argument. Unlike `schema.assertType(value)`,
408
+ this accepts a schema built inline, because the call target is the imported `assertType` — see
409
+ [the two syntactic requirements](#asserttype-has-two-syntactic-requirements).
410
+
411
+ ```ts
412
+ assertType(input, userSchema); // narrows `input` to User
413
+ assertType(input, object({ id: number() })); // inline schema: also fine
414
+ ```
415
+
416
+ Error: `Type assertion failed` — always this message, whichever schema failed.
417
+
418
+ ## Recipes
419
+
420
+ ### Nullable fields
421
+
422
+ ```ts
423
+ import { Schema, number, object, of, string, union } from "@pinesw/rttg";
424
+
425
+ interface Profile {
426
+ id: number;
427
+ nickname: string | null;
428
+ }
429
+
430
+ const profileSchema: Schema<Profile> = object({
431
+ id: number(),
432
+ nickname: union(string(), of(null)),
433
+ });
434
+ ```
435
+
436
+ ### Enums / literal unions
437
+
438
+ Use `as const` (or an explicit type argument) so the literal does not widen:
439
+
440
+ ```ts
441
+ const statusSchema: Schema<"open" | "closed"> = union(
442
+ of("open" as const),
443
+ of("closed" as const),
444
+ );
445
+
446
+ statusSchema.validate("open"); // true
447
+ statusSchema.validate("other"); // false
448
+ ```
449
+
450
+ Without `as const`, `of("open")` infers `Schema<string>` and the annotation rejects it:
451
+
452
+ ```
453
+ status-schema.ts(3,7): error TS2322: Type 'Schema<string>' is not assignable to type 'Schema<"closed" | "open">'.
454
+ Types of property 'validate' are incompatible.
455
+ Type '(value: unknown) => value is string' is not assignable to type '(value: unknown) => value is "closed" | "open"'.
456
+ Type predicate 'value is string' is not assignable to 'value is "closed" | "open"'.
457
+ Type 'string' is not assignable to type '"closed" | "open"'.
458
+ ```
459
+
460
+ `of<"open">("open")` works too. Inside an annotated `object({ ... })` the annotation supplies the
461
+ contextual type, so a bare `of("circle")` is fine there — as in the next recipe.
462
+
463
+ ### Discriminated unions
464
+
465
+ ```ts
466
+ import { Schema, number, object, of, union } from "@pinesw/rttg";
467
+
468
+ interface Circle { kind: "circle"; radius: number }
469
+ interface Square { kind: "square"; size: number }
470
+ type Shape = Circle | Square;
471
+
472
+ const circleSchema: Schema<Circle> = object({ kind: of("circle"), radius: number() });
473
+ const squareSchema: Schema<Square> = object({ kind: of("square"), size: number() });
474
+ const shapeSchema: Schema<Shape> = union(circleSchema, squareSchema);
475
+
476
+ function area(value: unknown): number {
477
+ const shape = shapeSchema.as(value);
478
+ return shape.kind === "circle"
479
+ ? Math.PI * shape.radius ** 2
480
+ : shape.size ** 2;
481
+ }
482
+ ```
483
+
484
+ Narrowing on `shape.kind` works after `as`, because the schema's type really is `Circle | Square`.
485
+ At runtime, `{ kind: "circle", size: 2 }` is rejected: it matches neither member.
486
+
487
+ ### Reusing and composing schemas
488
+
489
+ Schemas are plain values, so name them and pass them around:
490
+
491
+ ```ts
492
+ interface Comment { id: number; body: string; pinned: boolean }
493
+ interface Post { id: number; comments: Comment[] }
494
+
495
+ const commentSchema: Schema<Comment> = object({
496
+ id: number(),
497
+ body: string(),
498
+ pinned: boolean(),
499
+ });
500
+
501
+ const postSchema: Schema<Post> = object({
502
+ id: number(),
503
+ comments: array(commentSchema),
504
+ });
505
+ ```
506
+
507
+ Annotating both means a change to `Comment` is reported at `commentSchema`, where it belongs,
508
+ rather than deep inside `postSchema`.
509
+
510
+ ### Validating at the `JSON.parse` boundary
511
+
512
+ `JSON.parse` returns `any`, which silently poisons everything downstream. `as` turns that `any`
513
+ into a checked `T` in a single expression — a good default at every I/O boundary:
514
+
515
+ ```ts
516
+ function parseComment(text: string): Comment {
517
+ return commentSchema.as(JSON.parse(text));
518
+ }
519
+
520
+ parseComment('{"id":1,"body":"hi","pinned":true}'); // { id: 1, body: "hi", pinned: true }
521
+ parseComment('{"id":"1","body":"hi","pinned":true}'); // throws: Object validation failed
522
+ ```
523
+
524
+ The same shape works for `await response.json()`, `localStorage.getItem` and message handlers.
525
+
526
+ ## Behaviour worth knowing
527
+
528
+ The library is small and deliberately literal about what it checks. Some of the consequences
529
+ below are design choices; a few are bugs. Both are listed, and labelled.
530
+
531
+ ### Errors say what failed, not where
532
+
533
+ Every message is one of a handful of fixed strings. There is no path to the offending property,
534
+ no failing index, and no `cause` chain — a failure deep inside a nested schema reports only the
535
+ outermost schema's message:
536
+
537
+ ```ts
538
+ const postSchema = object({ comments: array(object({ body: string() })) });
539
+
540
+ postSchema.as({ comments: [{ body: 1 }] }); // throws: Object validation failed
541
+ ```
542
+
543
+ `union()` and `intersection()` swallow their members' messages the same way: a failing
544
+ `union(string(), number())` throws `Union validation failed`, never `Expected string`. That makes
545
+ these schemas well suited to a boundary check where the answer is yes or no, and poorly suited to
546
+ a user-facing validator that has to say *which* field was wrong. If you need that, validate the
547
+ parts separately and report per part, or pick a library that carries issue paths
548
+ ([zod](https://zod.dev/), [typia](https://typia.io/)).
549
+
550
+ ### Extra keys pass at runtime, by design
551
+
552
+ ```ts
553
+ object({ x: number() }).validate({ x: 1, y: 2 }); // true
554
+ ```
555
+
556
+ `object()` checks the keys you listed and ignores the rest, which is what you want for API
557
+ responses that grow new fields. Extra keys in the *schema* are caught at compile time by the
558
+ annotation — that is the division of labour: the runtime check is permissive, the compile-time
559
+ check is exact.
560
+
561
+ ### Optional properties are not expressible
562
+
563
+ `object()` requires the key to be **present**:
564
+
565
+ ```ts
566
+ object({ a: of(undefined) }).validate({}); // false — key missing
567
+ object({ a: of(undefined) }).validate({ a: undefined }); // true — key present
568
+ ```
569
+
570
+ So a type like `{ title?: string }` cannot be matched by any schema. The obvious attempt is
571
+ rejected, because an optional property is not the same as a required one that may be `undefined`:
572
+
573
+ ```ts
574
+ interface Draft { title?: string }
575
+
576
+ const draftSchema: Schema<Draft> = object({
577
+ title: union(string(), of(undefined)),
578
+ });
579
+ ```
580
+
581
+ ```
582
+ draft-schema.ts(7,7): error TS2322: Type 'Schema<{ title: string | undefined; }>' is not assignable to type 'Schema<Draft>'.
583
+ Types of property 'bivarianceHack' are incompatible.
584
+ Type '((arg: { title: string | undefined; }) => void) | undefined' is not assignable to type '((arg: Draft) => void) | undefined'.
585
+ Type '(arg: { title: string | undefined; }) => void' is not assignable to type '(arg: Draft) => void'.
586
+ Types of parameters 'arg' and 'arg' are incompatible.
587
+ Type 'Draft' is not assignable to type '{ title: string | undefined; }'.
588
+ Property 'title' is optional in type 'Draft' but required in type '{ title: string | undefined; }'.
589
+ ```
590
+
591
+ Model the field as required-and-nullable (`title: string | null`) where you can, or leave the key
592
+ out of the schema and check it yourself. Leaving it out does satisfy the annotation —
593
+ `const draftSchema: Schema<Draft> = object({})` compiles, because `{}` is assignable to an
594
+ all-optional interface in both variance directions — which is worth knowing for what it implies:
595
+ an annotated `Schema<Draft>` guarantees nothing at all about `title`.
596
+
597
+ ### `object()` is loose about what counts as an object
598
+
599
+ It tests `typeof value === "object" && value !== null`, then uses the `in` operator. Three
600
+ consequences:
601
+
602
+ ```ts
603
+ object({}).validate([]); // true — arrays are typeof "object"
604
+ object({ a: number() }).validate(Object.create({ a: 1 })); // true — `in` sees inherited properties
605
+ object({ length: number() }).validate(new String("ab")); // true — a boxed String has a length
606
+ ```
607
+
608
+ If you need to exclude arrays or prototype-provided properties, add your own check; the library
609
+ will not do it for you.
610
+
611
+ There is one way it is *stricter* than JavaScript, though: a function fails, because `typeof` a
612
+ function is `"function"` rather than `"object"`.
613
+
614
+ ```ts
615
+ object({}).validate(() => {}); // false — even though a function is an object in JS
616
+ ```
617
+
618
+ A function carrying the required properties cannot satisfy an `object()` schema either. Arguably
619
+ a bug; current behaviour either way.
620
+
621
+ ### `object()` never checks symbol keys — bug
622
+
623
+ `object()` walks its schema with `for (const key in schema)`, and `for...in` never enumerates
624
+ symbol keys. Every symbol-keyed sub-schema is therefore skipped, in both directions: a value
625
+ missing the property passes, and so does one whose property holds the wrong type. The annotation
626
+ accepts such a schema happily, so nothing warns you.
627
+
628
+ ```ts
629
+ const id: unique symbol = Symbol("id");
630
+
631
+ interface Tagged {
632
+ [id]: number;
633
+ name: string;
634
+ }
635
+
636
+ const tagged: Schema<Tagged> = object({ [id]: number(), name: string() }); // compiles
637
+
638
+ tagged.validate({ name: "ada" }); // true (!) — property absent
639
+ tagged.validate({ name: "ada", [id]: "not a number" }); // true (!) — property unchecked
640
+ tagged.as({ name: "ada" })[id]; // undefined, though typed number
641
+ ```
642
+
643
+ A schema whose keys are *all* symbols checks nothing beyond "is a non-null object". This is the
644
+ same unsoundness as the sparse-array bug below — the caller gets a value statically typed
645
+ `Tagged` whose property does not exist at runtime — so check symbol-keyed properties yourself, or
646
+ key the type by string.
647
+
648
+ ### `number()` accepts `NaN` and `Infinity`
649
+
650
+ ```ts
651
+ number().validate(NaN); // true
652
+ number().validate(Infinity); // true
653
+ ```
654
+
655
+ This matches TypeScript, where both are of type `number`. It is often *not* what a validator at a
656
+ JSON boundary wants, so test for finiteness separately when it matters. Boxed numbers are
657
+ rejected, since they are not `typeof "number"`:
658
+
659
+ ```ts
660
+ number().validate(new Number(1)); // false
661
+ ```
662
+
663
+ ### `of()` compares with `===`
664
+
665
+ Fine for primitives, surprising for objects and `NaN`:
666
+
667
+ ```ts
668
+ of(null).validate(undefined); // false — strict comparison, no null/undefined conflation
669
+ of({}).validate({}); // false — different references
670
+ of(NaN).validate(NaN); // false — NaN !== NaN
671
+ ```
672
+
673
+ For objects, reach for `object()` or `instanceOf()` instead of `of()`.
674
+
675
+ ### Sparse arrays pass `array()` and `tuple()` vacuously — bug
676
+
677
+ `Array.prototype.every` skips holes, so an array of nothing but holes satisfies any element
678
+ schema:
679
+
680
+ ```ts
681
+ array(number()).validate(new Array(3)); // true (!)
682
+ tuple(string(), string()).validate(new Array(2)); // true (!)
683
+ ```
684
+
685
+ This is a genuine bug: nothing was checked. A *present* `undefined` is handled correctly, which
686
+ shows the hole is the problem rather than the value:
687
+
688
+ ```ts
689
+ array(number()).validate([undefined]); // false
690
+ ```
691
+
692
+ ### `of()` throws when the schema is constructed, for anything it cannot stringify — bug
693
+
694
+ The error message is built eagerly by string interpolation (`Expected ${value}`), so the throw
695
+ happens when the *schema* is created, long before any value is validated:
696
+
697
+ ```ts
698
+ of(Symbol("x")); // throws: Cannot convert a Symbol value to a string
699
+ of(Object.create(null)); // throws: Cannot convert object to primitive value
700
+ of({ toString() { throw new Error("boom"); } }); // throws: boom
701
+ ```
702
+
703
+ A symbol is the familiar instance, but the real trigger is any value whose string conversion
704
+ throws — a prototype-less object has no inherited `toString` to call, and a throwing `toString`
705
+ propagates straight out of the `of()` call. Because it happens at construction time, such a
706
+ schema at module scope takes the whole module down on import.
707
+
708
+ `of()` simply cannot be used for those values; compare them yourself instead. Values that do
709
+ stringify are fine, including ones you might expect to trip it: `of(1n)` builds, and
710
+ `of(1n).as(2n)` throws `Expected 1`.
711
+
712
+ ### Methods must be called on the schema, not destructured — bug
713
+
714
+ `assertType` and `as` call `this.validate`, so they lose their receiver when detached:
715
+
716
+ ```ts
717
+ const { assertType } = string();
718
+ assertType("ok"); // throws: Cannot read properties of undefined (reading 'validate')
719
+ ```
720
+
721
+ `validate` itself survives destructuring (it is a plain closure), but the other two do not. Call
722
+ them as methods, or use the standalone `assertType(value, schema)`.
723
+
724
+ ### Degenerate `union()` and `intersection()`
725
+
726
+ With no arguments, `some()` is `false` and `every()` is `true`:
727
+
728
+ ```ts
729
+ union().validate("anything"); // false — matches nothing; the type is Schema<never>
730
+ intersection().validate("anything"); // true — matches everything; the type is Schema<unknown>
731
+ ```
732
+
733
+ Consistent with the underlying array methods, and worth remembering if you build schema lists
734
+ that can come out empty.
735
+
736
+ ## Development
737
+
738
+ The library is a single file, `index.ts`. Clone, then:
2
739
 
3
740
  ```bash
4
- $ npx tsc
741
+ npm install
5
742
  ```
743
+
744
+ | Script | Command | What it does |
745
+ | --- | --- | --- |
746
+ | `npm run build` | `tsc` | Emits `index.js`, `index.d.ts` and source maps from `index.ts`. |
747
+ | `npm test` | `vitest run --typecheck` | Runs the runtime tests *and* the type-level tests. |
748
+ | `npm run test:watch` | `vitest --typecheck` | The same, in watch mode. |
749
+ | `npm run typecheck` | `tsc -p tsconfig.test.json` | Typechecks `index.ts` plus everything under `test/`, with `strict` on. |
750
+
751
+ Tests live in `test/` and come in two kinds:
752
+
753
+ - **`*.test.ts` — runtime tests.** Ordinary Vitest specs that call `validate` / `assertType` /
754
+ `as` and assert on the results and the thrown messages.
755
+ - **`*.test-d.ts` — type-level tests.** These are checked by `tsc`, not executed. They are the
756
+ only way to test what this library is really about: that a drifted schema *fails to compile*.
757
+ Vitest runs them via `--typecheck`, which is why `npm test` passes that flag.
758
+
759
+ Tests import the library by its package name:
760
+
761
+ ```ts
762
+ import { object, string } from "@pinesw/rttg";
763
+ ```
764
+
765
+ `vitest.config.mts` aliases `@pinesw/rttg` to `./index.ts`, and `tsconfig.test.json` mirrors that
766
+ with a `paths` entry. This is deliberate: a relative `../index` import would let Vite resolve the
767
+ compiled `index.js` ahead of `index.ts`, so a stale build left over from `npm run build` could end
768
+ up under test instead of the source. Importing the package name also means the tests go through
769
+ the same entry point a consumer sees.
770
+
771
+ ## License
772
+
773
+ [Apache-2.0](LICENSE)
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@pinesw/rttg",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Run-Time Type Guards",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "build": "tsc",
9
+ "test": "vitest run --typecheck",
10
+ "test:watch": "vitest --typecheck",
11
+ "typecheck": "tsc -p tsconfig.test.json"
9
12
  },
10
13
  "files": [
11
14
  "index.ts",
@@ -20,6 +23,7 @@
20
23
  "license": "Apache-2.0",
21
24
  "devDependencies": {
22
25
  "@types/node": "^24.3.0",
23
- "typescript": "^5.9.2"
26
+ "typescript": "^5.9.2",
27
+ "vitest": "^5.0.4"
24
28
  }
25
29
  }