@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.
- package/README.md +770 -2
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,5 +1,773 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|