ts-fortress 4.3.0 → 4.3.1
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 +424 -569
- package/dist/array/array-of-length.mjs +3 -3
- package/dist/array/array.mjs +1 -1
- package/dist/array/non-empty-array.mjs +1 -1
- package/dist/array/tuple.mjs +2 -2
- package/dist/brand/brand.d.mts.map +1 -0
- package/dist/{branded → brand}/brand.mjs +1 -1
- package/dist/brand/brand.mjs.map +1 -0
- package/dist/brand/branded-number.d.mts +13 -0
- package/dist/brand/branded-number.d.mts.map +1 -0
- package/dist/brand/branded-number.mjs +18 -0
- package/dist/brand/branded-number.mjs.map +1 -0
- package/dist/brand/branded-string.d.mts +13 -0
- package/dist/brand/branded-string.d.mts.map +1 -0
- package/dist/brand/branded-string.mjs +18 -0
- package/dist/brand/branded-string.mjs.map +1 -0
- package/dist/brand/index.d.mts +4 -0
- package/dist/brand/index.d.mts.map +1 -0
- package/dist/brand/index.mjs +4 -0
- package/dist/{branded → brand}/index.mjs.map +1 -1
- package/dist/compose/index.d.mts +0 -1
- package/dist/compose/index.d.mts.map +1 -1
- package/dist/compose/index.mjs +0 -1
- package/dist/compose/index.mjs.map +1 -1
- package/dist/compose/intersection.mjs +1 -1
- package/dist/compose/merge-records.mjs +2 -2
- package/dist/compose/union.mjs +2 -2
- package/dist/entry-point.mjs +20 -20
- package/dist/enum/enum.mjs +3 -3
- package/dist/enum/int-range.mjs +2 -2
- package/dist/enum/uint-range.mjs +2 -2
- package/dist/globals.d.mts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +20 -20
- package/dist/other-types/index.d.mts +1 -0
- package/dist/other-types/index.d.mts.map +1 -1
- package/dist/other-types/index.mjs +1 -0
- package/dist/other-types/index.mjs.map +1 -1
- package/dist/other-types/map.d.mts.map +1 -1
- package/dist/other-types/map.mjs +3 -2
- package/dist/other-types/map.mjs.map +1 -1
- package/dist/other-types/recursion.d.mts.map +1 -0
- package/dist/other-types/recursion.mjs.map +1 -0
- package/dist/other-types/set.d.mts.map +1 -1
- package/dist/other-types/set.mjs +3 -2
- package/dist/other-types/set.mjs.map +1 -1
- package/dist/predefined/brand/index.d.mts +2 -0
- package/dist/predefined/brand/index.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/index.mjs +0 -3
- package/dist/predefined/brand/number/finite-number.d.mts +3 -0
- package/dist/predefined/brand/number/finite-number.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/finite-number.mjs +2 -2
- package/dist/predefined/brand/number/finite-number.mjs.map +1 -0
- package/dist/predefined/brand/number/index.d.mts.map +1 -0
- package/dist/predefined/brand/number/index.mjs.map +1 -0
- package/dist/predefined/brand/number/int.d.mts +3 -0
- package/dist/predefined/brand/number/int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/int.mjs +2 -2
- package/dist/predefined/brand/number/int.mjs.map +1 -0
- package/dist/predefined/brand/number/int16.d.mts +3 -0
- package/dist/predefined/brand/number/int16.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/int16.mjs +2 -2
- package/dist/predefined/brand/number/int16.mjs.map +1 -0
- package/dist/predefined/brand/number/int32.d.mts +3 -0
- package/dist/predefined/brand/number/int32.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/int32.mjs +2 -2
- package/dist/predefined/brand/number/int32.mjs.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-negative-finite-number.d.mts +1 -1
- package/dist/predefined/brand/number/non-negative-finite-number.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-negative-finite-number.mjs +2 -2
- package/dist/predefined/brand/number/non-negative-finite-number.mjs.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-zero-finite-number.d.mts +1 -1
- package/dist/predefined/brand/number/non-zero-finite-number.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-zero-finite-number.mjs +2 -2
- package/dist/predefined/brand/number/non-zero-finite-number.mjs.map +1 -0
- package/dist/predefined/brand/number/non-zero-int.d.mts +3 -0
- package/dist/predefined/brand/number/non-zero-int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-zero-int.mjs +2 -2
- package/dist/predefined/brand/number/non-zero-int.mjs.map +1 -0
- package/dist/predefined/brand/number/non-zero-safe-int.d.mts +3 -0
- package/dist/predefined/brand/number/non-zero-safe-int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/non-zero-safe-int.mjs +2 -2
- package/dist/predefined/brand/number/non-zero-safe-int.mjs.map +1 -0
- package/dist/{branded → predefined/brand}/number/positive-finite-number.d.mts +1 -1
- package/dist/predefined/brand/number/positive-finite-number.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/positive-finite-number.mjs +2 -2
- package/dist/predefined/brand/number/positive-finite-number.mjs.map +1 -0
- package/dist/predefined/brand/number/positive-int.d.mts +3 -0
- package/dist/predefined/brand/number/positive-int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/positive-int.mjs +2 -2
- package/dist/predefined/brand/number/positive-int.mjs.map +1 -0
- package/dist/{branded → predefined/brand}/number/positive-safe-int.d.mts +1 -1
- package/dist/predefined/brand/number/positive-safe-int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/positive-safe-int.mjs +2 -2
- package/dist/predefined/brand/number/positive-safe-int.mjs.map +1 -0
- package/dist/predefined/brand/number/safe-int.d.mts +3 -0
- package/dist/predefined/brand/number/safe-int.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/safe-int.mjs +2 -2
- package/dist/predefined/brand/number/safe-int.mjs.map +1 -0
- package/dist/predefined/brand/number/safe-uint.d.mts +3 -0
- package/dist/predefined/brand/number/safe-uint.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/safe-uint.mjs +2 -2
- package/dist/predefined/brand/number/safe-uint.mjs.map +1 -0
- package/dist/predefined/brand/number/uint.d.mts +3 -0
- package/dist/predefined/brand/number/uint.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/uint.mjs +2 -2
- package/dist/predefined/brand/number/uint.mjs.map +1 -0
- package/dist/predefined/brand/number/uint16.d.mts +3 -0
- package/dist/predefined/brand/number/uint16.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/uint16.mjs +2 -2
- package/dist/predefined/brand/number/uint16.mjs.map +1 -0
- package/dist/predefined/brand/number/uint32.d.mts +3 -0
- package/dist/predefined/brand/number/uint32.d.mts.map +1 -0
- package/dist/{branded → predefined/brand}/number/uint32.mjs +2 -2
- package/dist/predefined/brand/number/uint32.mjs.map +1 -0
- package/dist/predefined/index.d.mts +1 -0
- package/dist/predefined/index.d.mts.map +1 -1
- package/dist/predefined/index.mjs +16 -0
- package/dist/predefined/index.mjs.map +1 -1
- package/dist/predefined/json.d.mts.map +1 -1
- package/dist/predefined/json.mjs +2 -1
- package/dist/predefined/json.mjs.map +1 -1
- package/dist/record/key-value-record.mjs +3 -3
- package/dist/record/keyof.mjs +2 -2
- package/dist/record/partial.mjs +1 -1
- package/dist/record/record.mjs +3 -3
- package/dist/record/required.mjs +2 -2
- package/dist/record/valueof.mjs +2 -2
- package/package.json +32 -28
- package/src/array/array-of-length.mts +3 -3
- package/src/array/array-of-length.test.mts +42 -46
- package/src/array/array.mts +1 -1
- package/src/array/array.test.mts +30 -33
- package/src/array/non-empty-array.mts +1 -1
- package/src/array/non-empty-array.test.mts +42 -46
- package/src/array/tuple.mts +2 -2
- package/src/array/tuple.test.mts +52 -57
- package/src/{branded → brand}/brand.mts +1 -1
- package/src/{branded/simple-branded-number.mts → brand/branded-number.mts} +6 -2
- package/src/{branded/simple-branded-number.test.mts → brand/branded-number.test.mts} +43 -47
- package/src/{branded/simple-branded-string.mts → brand/branded-string.mts} +6 -2
- package/src/{branded/simple-branded-string.test.mts → brand/branded-string.test.mts} +45 -49
- package/src/brand/index.mts +3 -0
- package/src/compose/index.mts +0 -1
- package/src/compose/intersection.mts +1 -1
- package/src/compose/intersection.test.mts +122 -131
- package/src/compose/merge-records.mts +2 -2
- package/src/compose/merge-records.options.test.mts +37 -0
- package/src/compose/merge-records.test.mts +34 -38
- package/src/compose/union.mts +2 -2
- package/src/compose/union.test.mts +16 -20
- package/src/enum/enum.mts +3 -3
- package/src/enum/enum.test.mts +15 -18
- package/src/enum/int-range.mts +2 -2
- package/src/enum/int-range.test.mts +69 -0
- package/src/enum/uint-range.mts +2 -2
- package/src/enum/uint-range.test.mts +15 -18
- package/src/globals.d.mts +1 -1
- package/src/index.mts +1 -1
- package/src/other-types/index.mts +1 -0
- package/src/other-types/literal.test.mts +108 -125
- package/src/other-types/map.mts +6 -3
- package/src/other-types/map.test.mts +19 -23
- package/src/other-types/recursion.cache.test.mts +42 -0
- package/src/{compose → other-types}/recursion.test.mts +2 -2
- package/src/other-types/set.mts +6 -3
- package/src/other-types/set.test.mts +20 -24
- package/src/predefined/brand/index.mts +1 -0
- package/src/{branded → predefined/brand}/number/finite-number.mts +3 -3
- package/src/{branded → predefined/brand}/number/finite-number.test.mts +30 -34
- package/src/{branded → predefined/brand}/number/int.mts +3 -3
- package/src/{branded → predefined/brand}/number/int.test.mts +34 -38
- package/src/{branded → predefined/brand}/number/int16.mts +3 -3
- package/src/{branded → predefined/brand}/number/int16.test.mts +36 -35
- package/src/{branded → predefined/brand}/number/int32.mts +3 -3
- package/src/{branded → predefined/brand}/number/int32.test.mts +39 -38
- package/src/{branded → predefined/brand}/number/non-negative-finite-number.mts +3 -3
- package/src/{branded → predefined/brand}/number/non-negative-finite-number.test.mts +30 -28
- package/src/{branded → predefined/brand}/number/non-zero-finite-number.mts +3 -3
- package/src/{branded → predefined/brand}/number/non-zero-finite-number.test.mts +36 -29
- package/src/{branded → predefined/brand}/number/non-zero-int.mts +3 -3
- package/src/{branded → predefined/brand}/number/non-zero-int.test.mts +36 -28
- package/src/{branded → predefined/brand}/number/non-zero-safe-int.mts +3 -3
- package/src/{branded → predefined/brand}/number/non-zero-safe-int.test.mts +37 -35
- package/src/{branded → predefined/brand}/number/positive-finite-number.mts +3 -3
- package/src/{branded → predefined/brand}/number/positive-finite-number.test.mts +36 -30
- package/src/{branded → predefined/brand}/number/positive-int.mts +3 -3
- package/src/{branded → predefined/brand}/number/positive-int.test.mts +51 -46
- package/src/{branded → predefined/brand}/number/positive-safe-int.mts +3 -3
- package/src/{branded → predefined/brand}/number/positive-safe-int.test.mts +33 -33
- package/src/{branded → predefined/brand}/number/safe-int.mts +3 -3
- package/src/{branded → predefined/brand}/number/safe-int.test.mts +20 -23
- package/src/{branded → predefined/brand}/number/safe-uint.mts +3 -3
- package/src/{branded → predefined/brand}/number/safe-uint.test.mts +43 -36
- package/src/{branded → predefined/brand}/number/uint.mts +3 -3
- package/src/{branded → predefined/brand}/number/uint.test.mts +51 -47
- package/src/{branded → predefined/brand}/number/uint16.mts +3 -3
- package/src/{branded → predefined/brand}/number/uint16.test.mts +44 -37
- package/src/{branded → predefined/brand}/number/uint32.mts +3 -3
- package/src/{branded → predefined/brand}/number/uint32.test.mts +44 -37
- package/src/predefined/index.mts +1 -0
- package/src/predefined/int8.test.mts +25 -0
- package/src/predefined/json.mts +2 -1
- package/src/predefined/uint8.test.mts +25 -0
- package/src/primitives/bigint.literal.test.mts +23 -0
- package/src/primitives/bigint.test.mts +18 -21
- package/src/primitives/boolean.literal.test.mts +23 -0
- package/src/primitives/boolean.test.mts +18 -21
- package/src/primitives/null.test.mts +24 -28
- package/src/primitives/number.literal.test.mts +23 -0
- package/src/primitives/number.test.mts +15 -18
- package/src/primitives/string.literal.test.mts +23 -0
- package/src/primitives/string.test.mts +17 -21
- package/src/primitives/symbol.test.mts +18 -21
- package/src/primitives/undefined.test.mts +24 -28
- package/src/record/key-value-record.mts +3 -3
- package/src/record/key-value-record.test.mts +46 -49
- package/src/record/keyof.empty.test.mts +14 -0
- package/src/record/keyof.mts +2 -2
- package/src/record/keyof.test.mts +19 -22
- package/src/record/omit.options.test.mts +24 -0
- package/src/record/omit.test.mts +30 -35
- package/src/record/partial.mts +1 -1
- package/src/record/partial.test.mts +83 -94
- package/src/record/pick.options.test.mts +24 -0
- package/src/record/pick.test.mts +30 -35
- package/src/record/record-allow-excess-properties.test.mts +79 -88
- package/src/record/record-strict-composition-simple.test.mts +37 -48
- package/src/record/record-strict-composition.test.mts +136 -163
- package/src/record/record.mts +3 -3
- package/src/record/record.test.mts +80 -87
- package/src/record/required.mts +2 -2
- package/src/record/required.test.mts +114 -127
- package/src/record/strict-record.test.mts +52 -58
- package/src/record/valueof.mts +2 -2
- package/src/record/valueof.test.mts +24 -166
- package/src/utils/validation-error.formatting.test.mts +47 -0
- package/dist/branded/brand.d.mts.map +0 -1
- package/dist/branded/brand.mjs.map +0 -1
- package/dist/branded/index.d.mts +0 -5
- package/dist/branded/index.d.mts.map +0 -1
- package/dist/branded/number/finite-number.d.mts +0 -3
- package/dist/branded/number/finite-number.d.mts.map +0 -1
- package/dist/branded/number/finite-number.mjs.map +0 -1
- package/dist/branded/number/index.d.mts.map +0 -1
- package/dist/branded/number/int.d.mts +0 -3
- package/dist/branded/number/int.d.mts.map +0 -1
- package/dist/branded/number/int.mjs.map +0 -1
- package/dist/branded/number/int16.d.mts +0 -3
- package/dist/branded/number/int16.d.mts.map +0 -1
- package/dist/branded/number/int16.mjs.map +0 -1
- package/dist/branded/number/int32.d.mts +0 -3
- package/dist/branded/number/int32.d.mts.map +0 -1
- package/dist/branded/number/int32.mjs.map +0 -1
- package/dist/branded/number/non-negative-finite-number.d.mts.map +0 -1
- package/dist/branded/number/non-negative-finite-number.mjs.map +0 -1
- package/dist/branded/number/non-zero-finite-number.d.mts.map +0 -1
- package/dist/branded/number/non-zero-finite-number.mjs.map +0 -1
- package/dist/branded/number/non-zero-int.d.mts +0 -3
- package/dist/branded/number/non-zero-int.d.mts.map +0 -1
- package/dist/branded/number/non-zero-int.mjs.map +0 -1
- package/dist/branded/number/non-zero-safe-int.d.mts +0 -3
- package/dist/branded/number/non-zero-safe-int.d.mts.map +0 -1
- package/dist/branded/number/non-zero-safe-int.mjs.map +0 -1
- package/dist/branded/number/positive-finite-number.d.mts.map +0 -1
- package/dist/branded/number/positive-finite-number.mjs.map +0 -1
- package/dist/branded/number/positive-int.d.mts +0 -3
- package/dist/branded/number/positive-int.d.mts.map +0 -1
- package/dist/branded/number/positive-int.mjs.map +0 -1
- package/dist/branded/number/positive-safe-int.d.mts.map +0 -1
- package/dist/branded/number/positive-safe-int.mjs.map +0 -1
- package/dist/branded/number/safe-int.d.mts +0 -3
- package/dist/branded/number/safe-int.d.mts.map +0 -1
- package/dist/branded/number/safe-int.mjs.map +0 -1
- package/dist/branded/number/safe-uint.d.mts +0 -3
- package/dist/branded/number/safe-uint.d.mts.map +0 -1
- package/dist/branded/number/safe-uint.mjs.map +0 -1
- package/dist/branded/number/uint.d.mts +0 -3
- package/dist/branded/number/uint.d.mts.map +0 -1
- package/dist/branded/number/uint.mjs.map +0 -1
- package/dist/branded/number/uint16.d.mts +0 -3
- package/dist/branded/number/uint16.d.mts.map +0 -1
- package/dist/branded/number/uint16.mjs.map +0 -1
- package/dist/branded/number/uint32.d.mts +0 -3
- package/dist/branded/number/uint32.d.mts.map +0 -1
- package/dist/branded/number/uint32.mjs.map +0 -1
- package/dist/branded/simple-branded-number.d.mts +0 -7
- package/dist/branded/simple-branded-number.d.mts.map +0 -1
- package/dist/branded/simple-branded-number.mjs +0 -15
- package/dist/branded/simple-branded-number.mjs.map +0 -1
- package/dist/branded/simple-branded-string.d.mts +0 -7
- package/dist/branded/simple-branded-string.d.mts.map +0 -1
- package/dist/branded/simple-branded-string.mjs +0 -15
- package/dist/branded/simple-branded-string.mjs.map +0 -1
- package/dist/compose/recursion.d.mts.map +0 -1
- package/dist/compose/recursion.mjs.map +0 -1
- package/src/branded/index.mts +0 -4
- /package/dist/{branded → brand}/brand.d.mts +0 -0
- /package/dist/{compose → other-types}/recursion.d.mts +0 -0
- /package/dist/{compose → other-types}/recursion.mjs +0 -0
- /package/dist/{branded/number → predefined/brand}/index.mjs.map +0 -0
- /package/dist/{branded → predefined/brand}/number/index.d.mts +0 -0
- /package/dist/{branded → predefined/brand}/number/index.mjs +0 -0
- /package/src/{compose → other-types}/recursion.mts +0 -0
- /package/src/{branded → predefined/brand}/number/index.mts +0 -0
package/README.md
CHANGED
|
@@ -35,7 +35,8 @@ pnpm add ts-fortress
|
|
|
35
35
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
38
|
-
```
|
|
38
|
+
```tsx
|
|
39
|
+
import { expectType } from 'ts-data-forge';
|
|
39
40
|
import * as t from 'ts-fortress';
|
|
40
41
|
|
|
41
42
|
// Define a schema
|
|
@@ -43,19 +44,23 @@ const User = t.record({
|
|
|
43
44
|
id: t.string(),
|
|
44
45
|
name: t.string(),
|
|
45
46
|
age: t.number(),
|
|
46
|
-
email: t.string(),
|
|
47
|
+
email: t.optional(t.string()),
|
|
47
48
|
isActive: t.boolean(),
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
// Infer TypeScript type
|
|
51
52
|
type User = t.TypeOf<typeof User>;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
|
|
54
|
+
expectType<
|
|
55
|
+
User,
|
|
56
|
+
Readonly<{
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
age: number;
|
|
60
|
+
email?: string;
|
|
61
|
+
isActive: boolean;
|
|
62
|
+
}>
|
|
63
|
+
>('=');
|
|
59
64
|
|
|
60
65
|
// Validate data
|
|
61
66
|
const userData = {
|
|
@@ -64,19 +69,29 @@ const userData = {
|
|
|
64
69
|
age: 30,
|
|
65
70
|
email: 'john@example.com',
|
|
66
71
|
isActive: true,
|
|
67
|
-
};
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
assert(User.is(userData));
|
|
68
75
|
|
|
69
76
|
if (User.is(userData)) {
|
|
70
77
|
// userData is now typed as User
|
|
71
|
-
|
|
78
|
+
userData satisfies User;
|
|
79
|
+
|
|
80
|
+
assert.equal(
|
|
81
|
+
`User: ${userData.name}, Age: ${userData.age}`,
|
|
82
|
+
'User: John Doe, Age: 30',
|
|
83
|
+
);
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
// Get validation result with error details
|
|
75
87
|
const result = User.validate(userData);
|
|
76
88
|
if (t.Result.isOk(result)) {
|
|
77
|
-
|
|
89
|
+
result.value satisfies User; // typed as User
|
|
78
90
|
} else {
|
|
79
|
-
console.error(
|
|
91
|
+
console.error(
|
|
92
|
+
'Validation errors:',
|
|
93
|
+
result.value satisfies readonly t.ValidationError[],
|
|
94
|
+
);
|
|
80
95
|
}
|
|
81
96
|
```
|
|
82
97
|
|
|
@@ -84,19 +99,19 @@ if (t.Result.isOk(result)) {
|
|
|
84
99
|
|
|
85
100
|
One of the key design decisions in ts-fortress is that **all schema types have explicit default values**, which allows for powerful data entry capabilities:
|
|
86
101
|
|
|
87
|
-
```
|
|
102
|
+
```tsx
|
|
88
103
|
import * as t from 'ts-fortress';
|
|
89
104
|
|
|
90
105
|
// Every type requires a default value
|
|
91
106
|
const UserProfile = t.record({
|
|
92
107
|
name: t.string('Anonymous'), // Default: 'Anonymous'
|
|
93
|
-
age: t.number(
|
|
94
|
-
email: t.optional(t.string(
|
|
108
|
+
age: t.number(), // Default: 0
|
|
109
|
+
email: t.optional(t.string()), // Optional field with default ''
|
|
95
110
|
preferences: t.record({
|
|
96
111
|
theme: t.string('light'), // Default: 'light'
|
|
97
112
|
notifications: t.boolean(true), // Default: true
|
|
98
113
|
}),
|
|
99
|
-
tags: t.array(t.string(
|
|
114
|
+
tags: t.array(t.string()), // Default: empty array []
|
|
100
115
|
});
|
|
101
116
|
|
|
102
117
|
// The fill() function automatically provides missing values
|
|
@@ -110,29 +125,39 @@ const partialData = {
|
|
|
110
125
|
};
|
|
111
126
|
|
|
112
127
|
const filledData = UserProfile.fill(partialData);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
|
|
129
|
+
assert.deepStrictEqual(filledData, {
|
|
130
|
+
name: 'John Doe',
|
|
131
|
+
age: 0, // ← Filled with default
|
|
132
|
+
email: '', // ← Filled with default
|
|
133
|
+
preferences: {
|
|
134
|
+
theme: 'dark',
|
|
135
|
+
notifications: true, // ← Filled with default
|
|
136
|
+
},
|
|
137
|
+
tags: [], // ← Filled with default
|
|
138
|
+
});
|
|
124
139
|
|
|
125
140
|
// fill() is type-safe and always returns a complete object
|
|
126
141
|
type UserProfile = t.TypeOf<typeof UserProfile>;
|
|
127
|
-
const completeUser: UserProfile = UserProfile.fill(anyPartialData);
|
|
128
142
|
|
|
129
143
|
// Important: Default value filling only occurs when fill() is called
|
|
130
144
|
// The is() and validate() functions can still detect missing keys
|
|
131
|
-
|
|
145
|
+
assert(!UserProfile.is(partialData)); // missing required keys
|
|
146
|
+
|
|
132
147
|
const result = UserProfile.validate(partialData);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
|
|
149
|
+
assert(t.Result.isErr(result));
|
|
150
|
+
|
|
151
|
+
assert.deepStrictEqual(
|
|
152
|
+
t.validationErrorsToMessages(
|
|
153
|
+
result.value satisfies readonly t.ValidationError[],
|
|
154
|
+
),
|
|
155
|
+
[
|
|
156
|
+
`Missing required key "age" at age`,
|
|
157
|
+
`Missing required key "notifications" at preferences.notifications`,
|
|
158
|
+
`Missing required key "tags" at tags`,
|
|
159
|
+
],
|
|
160
|
+
);
|
|
136
161
|
```
|
|
137
162
|
|
|
138
163
|
### Benefits of Default Values
|
|
@@ -148,7 +173,9 @@ if (t.Result.isErr(result)) {
|
|
|
148
173
|
|
|
149
174
|
Most ts-fortress types provide sensible defaults automatically, so you rarely need to specify explicit default values:
|
|
150
175
|
|
|
151
|
-
```
|
|
176
|
+
```tsx
|
|
177
|
+
import * as t from 'ts-fortress';
|
|
178
|
+
|
|
152
179
|
// Most common types have built-in defaults
|
|
153
180
|
const Schema = t.record({
|
|
154
181
|
name: t.string(), // defaults to ""
|
|
@@ -156,14 +183,16 @@ const Schema = t.record({
|
|
|
156
183
|
active: t.boolean(), // defaults to false
|
|
157
184
|
tags: t.array(t.string()), // defaults to []
|
|
158
185
|
config: t.record({
|
|
159
|
-
debug: t.boolean(), // defaults to false
|
|
186
|
+
debug: t.nullable(t.boolean()), // defaults to false
|
|
160
187
|
}), // defaults to { debug: false }
|
|
161
188
|
});
|
|
162
189
|
```
|
|
163
190
|
|
|
164
191
|
You only need to specify explicit default values in two cases: when you want custom values, or when using `intersection` types:
|
|
165
192
|
|
|
166
|
-
```
|
|
193
|
+
```tsx
|
|
194
|
+
import * as t from 'ts-fortress';
|
|
195
|
+
|
|
167
196
|
// Custom default values
|
|
168
197
|
const ServerConfig = t.record({
|
|
169
198
|
port: t.number(3000), // custom default: 3000
|
|
@@ -171,22 +200,32 @@ const ServerConfig = t.record({
|
|
|
171
200
|
retries: t.number(5), // custom default: 5
|
|
172
201
|
});
|
|
173
202
|
|
|
174
|
-
|
|
203
|
+
assert.deepStrictEqual(ServerConfig.defaultValue, {
|
|
204
|
+
port: 3000,
|
|
205
|
+
host: 'localhost',
|
|
206
|
+
retries: 5,
|
|
207
|
+
} satisfies t.TypeOf<typeof ServerConfig>);
|
|
208
|
+
|
|
209
|
+
// Enum types have built-in defaults
|
|
175
210
|
|
|
176
|
-
const
|
|
211
|
+
const JobStatus = t.enumType(['started', 'scheduled', 'succeeded', 'failed']); // default: "started"
|
|
212
|
+
|
|
213
|
+
const JobFulfilledStatus = t.enumType(['succeeded', 'failed', 'cancelled']); // default: "succeeded"
|
|
177
214
|
|
|
178
215
|
// Intersection types require explicit defaults
|
|
179
216
|
const ReportStatus = t.intersection(
|
|
180
217
|
[JobStatus, JobFulfilledStatus],
|
|
181
|
-
t.enumType(['
|
|
218
|
+
t.enumType(['succeeded', 'failed']), // must provide combined default
|
|
182
219
|
);
|
|
183
220
|
```
|
|
184
221
|
|
|
185
222
|
This is because intersection types can be created from arbitrary types, making it impossible to automatically determine appropriate default values. However, when all constituent types are record types, you can use the `mergeRecords` function to avoid specifying defaults:
|
|
186
223
|
|
|
187
|
-
```
|
|
224
|
+
```tsx
|
|
225
|
+
import * as t from 'ts-fortress';
|
|
226
|
+
|
|
188
227
|
// Using mergeRecords for record-only intersections
|
|
189
|
-
const UserWithMetadata = t.mergeRecords(
|
|
228
|
+
const UserWithMetadata = t.mergeRecords([
|
|
190
229
|
t.record({
|
|
191
230
|
id: t.string(),
|
|
192
231
|
name: t.string(),
|
|
@@ -195,441 +234,58 @@ const UserWithMetadata = t.mergeRecords(
|
|
|
195
234
|
createdAt: t.number(),
|
|
196
235
|
updatedAt: t.number(),
|
|
197
236
|
}),
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
237
|
+
// No explicit default needed - automatically combines defaults from both records
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
assert.deepStrictEqual(UserWithMetadata.defaultValue, {
|
|
241
|
+
id: '',
|
|
242
|
+
name: '',
|
|
243
|
+
createdAt: 0,
|
|
244
|
+
updatedAt: 0,
|
|
245
|
+
} satisfies t.TypeOf<typeof UserWithMetadata>);
|
|
201
246
|
```
|
|
202
247
|
|
|
203
248
|
## Why ts-fortress over Zod and io-ts?
|
|
204
249
|
|
|
205
250
|
While ts-fortress, [Zod](https://github.com/colinhacks/zod), and [io-ts](https://github.com/gcanti/io-ts) are all excellent TypeScript validation libraries, ts-fortress offers more readable and informative error messages than both, a more type-safe way of building validators than Zod, and addresses some critical runtime consistency issues found in io-ts.
|
|
206
251
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
**Problem with Zod**: The following code compiles without errors but creates an invalid schema:
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
import * as z from 'zod';
|
|
213
|
-
|
|
214
|
-
// ❌ This compiles but is incorrect!
|
|
215
|
-
export const SomeObject = z.object({
|
|
216
|
-
key1: 1, // Should be z.literal(1)
|
|
217
|
-
key2: 'string', // Should be z.string()
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
export type SomeObject = z.infer<typeof SomeObject>; // inferred as { key1: unknown, key2: unknown }
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
The above Zod schema will fail at runtime because raw values (`1`, `'string'`) are not valid Zod validators.
|
|
224
|
-
|
|
225
|
-
**Correct Zod usage** requires remembering to wrap all values:
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
// ✅ Correct Zod usage
|
|
229
|
-
export const SomeObject = z.object({
|
|
230
|
-
key1: z.literal(1),
|
|
231
|
-
key2: z.string(),
|
|
232
|
-
});
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
**ts-fortress prevents this error at compile time**:
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
import * as t from 'ts-fortress';
|
|
239
|
-
|
|
240
|
-
// ❌ TypeScript error - this won't compile!
|
|
241
|
-
export const SomeObject = t.record({
|
|
242
|
-
key1: 1, // Error: number is not assignable to Type<unknown>
|
|
243
|
-
key2: 'string', // Error: string is not assignable to Type<unknown>
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// ✅ Correct ts-fortress usage - enforced by TypeScript
|
|
247
|
-
export const SomeObject = t.record({
|
|
248
|
-
key1: t.literal(1), // or t.number(1) with default
|
|
249
|
-
key2: t.string(''),
|
|
250
|
-
});
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
### Benefits
|
|
254
|
-
|
|
255
|
-
- **Compile-time safety**: TypeScript catches invalid schema definitions immediately
|
|
256
|
-
- **IDE support**: Better autocomplete and error highlighting during development
|
|
257
|
-
- **Reduced runtime errors**: Impossible to create invalid schemas that fail at runtime
|
|
258
|
-
- **Self-documenting**: The type system guides you toward correct usage
|
|
259
|
-
|
|
260
|
-
### Deep Readonly Types by Default
|
|
261
|
-
|
|
262
|
-
**ts-fortress generates deeply readonly types**, promoting immutability and preventing accidental mutations:
|
|
263
|
-
|
|
264
|
-
```typescript
|
|
265
|
-
import * as t from 'ts-fortress';
|
|
266
|
-
|
|
267
|
-
const UserType = t.record({
|
|
268
|
-
name: t.string(''),
|
|
269
|
-
address: t.record({
|
|
270
|
-
street: t.string(''),
|
|
271
|
-
city: t.string(''),
|
|
272
|
-
}),
|
|
273
|
-
tags: t.array(t.string('')),
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
type User = t.TypeOf<typeof UserType>;
|
|
277
|
-
// ↑ Readonly<{
|
|
278
|
-
// name: string;
|
|
279
|
-
// address: Readonly<{
|
|
280
|
-
// street: string;
|
|
281
|
-
// city: string;
|
|
282
|
-
// }>;
|
|
283
|
-
// tags: readonly string[];
|
|
284
|
-
// }>
|
|
285
|
-
|
|
286
|
-
const user: User = UserType.cast(someData);
|
|
287
|
-
|
|
288
|
-
// ❌ All of these produce TypeScript errors:
|
|
289
|
-
user.name = 'new name'; // Cannot assign to 'name' because it is read-only
|
|
290
|
-
user.address.street = 'new street'; // Cannot assign to 'street' because it is read-only
|
|
291
|
-
user.tags.push('new tag'); // Property 'push' does not exist on readonly array
|
|
292
|
-
user.tags[0] = 'modified'; // Index signature in type 'readonly string[]' only permits reading
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
**In contrast, Zod types are mutable by default**:
|
|
296
|
-
|
|
297
|
-
```typescript
|
|
298
|
-
import * as z from 'zod';
|
|
299
|
-
|
|
300
|
-
const UserSchema = z.object({
|
|
301
|
-
name: z.string(),
|
|
302
|
-
address: z.object({
|
|
303
|
-
street: z.string(),
|
|
304
|
-
city: z.string(),
|
|
305
|
-
}),
|
|
306
|
-
tags: z.array(z.string()),
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
type User = z.infer<typeof UserSchema>;
|
|
310
|
-
// ↑ {
|
|
311
|
-
// name: string;
|
|
312
|
-
// address: {
|
|
313
|
-
// street: string;
|
|
314
|
-
// city: string;
|
|
315
|
-
// };
|
|
316
|
-
// tags: string[];
|
|
317
|
-
// }
|
|
318
|
-
|
|
319
|
-
const user: User = UserSchema.parse(someData);
|
|
320
|
-
|
|
321
|
-
// ✅ These all work in Zod (but may not be desirable):
|
|
322
|
-
user.name = 'new name'; // No error
|
|
323
|
-
user.address.street = 'new street'; // No error
|
|
324
|
-
user.tags.push('new tag'); // No error
|
|
325
|
-
user.tags[0] = 'modified'; // No error
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
**In io-ts, Readonly types will generate verbose error messages**:
|
|
329
|
-
|
|
330
|
-
```typescript
|
|
331
|
-
import * as ioTs from 'io-ts';
|
|
332
|
-
import { PathReporter } from 'io-ts/PathReporter';
|
|
333
|
-
|
|
334
|
-
// io-ts nested readonly version with multiple Readonly wrappers
|
|
335
|
-
const IoTsNestedReadonly = ioTs.readonly(
|
|
336
|
-
ioTs.type({
|
|
337
|
-
user: ioTs.readonly(
|
|
338
|
-
ioTs.type({
|
|
339
|
-
profile: ioTs.readonly(
|
|
340
|
-
ioTs.type({
|
|
341
|
-
age: ioTs.number,
|
|
342
|
-
}),
|
|
343
|
-
),
|
|
344
|
-
}),
|
|
345
|
-
),
|
|
346
|
-
}),
|
|
347
|
-
);
|
|
348
|
-
|
|
349
|
-
const invalidData = {
|
|
350
|
-
user: {
|
|
351
|
-
profile: {
|
|
352
|
-
age: 'not-a-number', // should be number
|
|
353
|
-
},
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
// Get io-ts error messages
|
|
358
|
-
const ioTsResult = IoTsNestedReadonly.decode(invalidData);
|
|
359
|
-
const ioTsErrorMessages = PathReporter.report(ioTsResult);
|
|
360
|
-
|
|
361
|
-
console.log(ioTsErrorMessages[0]);
|
|
362
|
-
// Output: Invalid value "not-a-number" supplied to : Readonly<{ user: Readonly<{ profile: Readonly<{ age: number }> }> }>/user: Readonly<{ profile: Readonly<{ age: number }> }>/profile: Readonly<{ age: number }>/age: number
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
**The error message contains excessive `Readonly<...>` wrapper noise:**
|
|
366
|
-
|
|
367
|
-
- Root level: `Readonly<{ user: Readonly<{ profile: Readonly<{ age: number }> }> }>`
|
|
368
|
-
- User level: `Readonly<{ profile: Readonly<{ age: number }> }>`
|
|
369
|
-
- Profile level: `Readonly<{ age: number }>`
|
|
370
|
-
- Each nested readonly creates exponentially verbose error messages with redundant type information
|
|
371
|
-
|
|
372
|
-
This verbosity makes error messages extremely difficult to read and debug in production environments.
|
|
373
|
-
|
|
374
|
-
**ts-fortress addresses this issue** by generating clean, actionable error messages without verbose type wrapper details:
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
import * as tf from 'ts-fortress';
|
|
378
|
-
|
|
379
|
-
// ts-fortress equivalent clean structure
|
|
380
|
-
const TsFortressNestedType = tf.record({
|
|
381
|
-
user: tf.record({
|
|
382
|
-
profile: tf.record({
|
|
383
|
-
age: tf.number(0),
|
|
384
|
-
}),
|
|
385
|
-
}),
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// Get ts-fortress error messages
|
|
389
|
-
const tsFortressResult = TsFortressNestedType.validate(invalidData);
|
|
390
|
-
const tsFortressErrorMessages = tf.Result.isErr(tsFortressResult)
|
|
391
|
-
? tf.validationErrorsToMessages(tsFortressResult.value)
|
|
392
|
-
: [];
|
|
393
|
-
|
|
394
|
-
console.log(tsFortressErrorMessages[0]);
|
|
395
|
-
// Clean output: Expected <number> at user.profile.age, got <string> type value "not-a-number".
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
**ts-fortress vs Zod comparison:**
|
|
399
|
-
|
|
400
|
-
```typescript
|
|
401
|
-
import { z } from 'zod';
|
|
402
|
-
|
|
403
|
-
// Zod nested readonly equivalent
|
|
404
|
-
const ZodNestedType = z
|
|
405
|
-
.object({
|
|
406
|
-
user: z
|
|
407
|
-
.object({
|
|
408
|
-
profile: z
|
|
409
|
-
.object({
|
|
410
|
-
age: z.number(),
|
|
411
|
-
})
|
|
412
|
-
.readonly(),
|
|
413
|
-
})
|
|
414
|
-
.readonly(),
|
|
415
|
-
})
|
|
416
|
-
.readonly();
|
|
417
|
-
|
|
418
|
-
// Get Zod error messages using prettifyError
|
|
419
|
-
const zodResult = ZodNestedType.safeParse(invalidData);
|
|
420
|
-
const zodErrorMessages = zodResult.success
|
|
421
|
-
? ''
|
|
422
|
-
: z.prettifyError(zodResult.error);
|
|
423
|
-
|
|
424
|
-
console.log(zodErrorMessages);
|
|
425
|
-
// Clean output: "✖ Invalid input: expected number, received string\n → at user.profile.age"
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
**Error message comparison:**
|
|
429
|
-
|
|
430
|
-
- **io-ts**: 148 characters of verbose type information mixed with the actual error
|
|
431
|
-
- **Zod**: 62 characters with visual formatting but missing the actual invalid value
|
|
432
|
-
- **ts-fortress**: 74 characters focused purely on what went wrong and where, including the actual invalid value
|
|
433
|
-
|
|
434
|
-
While Zod produces cleaner error messages than io-ts and includes helpful visual formatting, **ts-fortress provides superior debugging experience** by including the actual invalid value (`"not-a-number"`) in the error message, making it easier to understand what data caused the validation failure.
|
|
435
|
-
|
|
436
|
-
### Benefits of Deep Readonly
|
|
437
|
-
|
|
438
|
-
- **Immutability by default**: Prevents accidental mutations that can lead to bugs
|
|
439
|
-
- **Functional programming support**: Encourages functional programming patterns
|
|
440
|
-
- **Predictable data flow**: Ensures data integrity throughout your application
|
|
441
|
-
- **Thread safety**: Immutable data is inherently safe to share between contexts
|
|
442
|
-
|
|
443
|
-
### Runtime-Type Consistency Issues in io-ts
|
|
444
|
-
|
|
445
|
-
**io-ts has several long-standing bugs** where runtime behavior doesn't match TypeScript types, which have remained unfixed for years:
|
|
446
|
-
|
|
447
|
-
#### 1. Keyof Type Mismatch ([Issue #697](https://github.com/gcanti/io-ts/issues/697))
|
|
448
|
-
|
|
449
|
-
```typescript
|
|
450
|
-
import * as t from 'io-ts';
|
|
451
|
-
|
|
452
|
-
const T = t.keyof({
|
|
453
|
-
0: undefined,
|
|
454
|
-
1: undefined,
|
|
455
|
-
2: undefined,
|
|
456
|
-
3: undefined,
|
|
457
|
-
4: undefined,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
// ❌ Runtime behavior is inconsistent with TypeScript types!
|
|
461
|
-
console.log(T.decode(0)); // Left (fails) - number 0 is rejected
|
|
462
|
-
console.log(T.decode('0')); // Right (succeeds) - string "0" is accepted
|
|
463
|
-
|
|
464
|
-
type T = t.TypeOf<typeof T>;
|
|
465
|
-
// ↑ TypeScript infers: 0 | 1 | 2 | 3 | 4 (number literals)
|
|
466
|
-
// But should be: "0" | "1" | "2" | "3" | "4" (string literals)
|
|
467
|
-
|
|
468
|
-
// The runtime validator only accepts strings, but TypeScript thinks it accepts numbers!
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
For this reason, the [io-ts documentation](https://github.com/gcanti/io-ts/blob/master/index.md#implemented-types--combinators) states that `t.keyof` only supports string keys (although this is somewhat dangerous, as the TypeScript type definition does not prevent the use of keys other than string).
|
|
472
|
-
|
|
473
|
-
#### 2. Union + Undefined Decode Issues ([Issue #677](https://github.com/gcanti/io-ts/issues/677))
|
|
474
|
-
|
|
475
|
-
```typescript
|
|
476
|
-
import * as t from 'io-ts';
|
|
477
|
-
import { isRight } from 'fp-ts/Either';
|
|
478
|
-
|
|
479
|
-
const A = t.type({
|
|
480
|
-
A: t.union([t.number, t.undefined, t.null]),
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
const B = t.type({
|
|
484
|
-
B: t.union([t.number, t.undefined, t.null]),
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
const C = t.partial({
|
|
488
|
-
C: t.union([t.number, t.null]),
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
// ❌ Case 1: Union decode adds unexpected fields
|
|
492
|
-
{
|
|
493
|
-
const UnionBA = t.union([B, A]);
|
|
494
|
-
const res = UnionBA.decode({ A: 1 });
|
|
495
|
-
|
|
496
|
-
if (isRight(res)) {
|
|
497
|
-
console.log(res.right); // { A: 1, B: undefined } <- NG (expected: { A: 1 })
|
|
498
|
-
console.log(A.is(res.right)); // true <- ok
|
|
499
|
-
console.log(B.is(res.right)); // true <- NG (expected: false)
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// ❌ Case 2: Union decode produces inconsistent results
|
|
504
|
-
{
|
|
505
|
-
const UnionCA = t.union([C, A]);
|
|
506
|
-
const res = UnionCA.decode({ A: 1 });
|
|
507
|
-
|
|
508
|
-
if (isRight(res)) {
|
|
509
|
-
console.log(res.right); // { A: 1 } <- NG (expected: {})
|
|
510
|
-
console.log(A.is(res.right)); // true <- ok
|
|
511
|
-
console.log(C.is(res.right)); // true <- ok
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
**ts-fortress eliminates these problems** by ensuring strict runtime-type consistency:
|
|
517
|
-
|
|
518
|
-
Keyof Type Mismatch:
|
|
519
|
-
|
|
520
|
-
```typescript
|
|
521
|
-
import * as t from 'ts-fortress';
|
|
522
|
-
|
|
523
|
-
// ✅ ts-fortress: Runtime and types always match
|
|
524
|
-
const T = t.keyof(
|
|
525
|
-
t.record({
|
|
526
|
-
0: t.undefinedType,
|
|
527
|
-
1: t.undefinedType,
|
|
528
|
-
2: t.undefinedType,
|
|
529
|
-
3: t.undefinedType,
|
|
530
|
-
4: t.undefinedType,
|
|
531
|
-
}),
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
type T = t.TypeOf<typeof T>;
|
|
535
|
-
// ↑ TypeScript correctly infers: "0" | "1" | "2" | "3" | "4" (string literals)
|
|
536
|
-
|
|
537
|
-
// ✅ Runtime behavior matches TypeScript types exactly
|
|
538
|
-
console.log(T.validate(0)); // ❌ Fails correctly - number 0 is rejected
|
|
539
|
-
console.log(T.validate('0')); // ✅ Success - string "0" is accepted
|
|
540
|
-
|
|
541
|
-
// For this use case, if you want to define a union type of numeric literals, you can use `uintRange` from ts-fortress:
|
|
542
|
-
|
|
543
|
-
const U = t.uintRange({ start: 0, end: 5 });
|
|
544
|
-
|
|
545
|
-
type U = t.TypeOf<typeof U>;
|
|
546
|
-
// ↑ TypeScript correctly infers: 0 | 1 | 2 | 3 | 4 (number literals)
|
|
547
|
-
|
|
548
|
-
console.log(U.validate('0')); // ❌ Fails - string "0" is rejected
|
|
549
|
-
console.log(U.validate(0)); // ✅ Success - number 0 is accepted
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
Union + Undefined Decode Issues:
|
|
553
|
-
|
|
554
|
-
```ts
|
|
555
|
-
import * as t from 'ts-fortress';
|
|
556
|
-
|
|
557
|
-
// ✅ Complex union types work reliably without unexpected behavior
|
|
558
|
-
const A = t.record({
|
|
559
|
-
A: t.union([t.number(0), t.undefinedType, t.nullType]),
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
const B = t.record({
|
|
563
|
-
B: t.union([t.number(0), t.undefinedType, t.nullType]),
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
const C = t.partial(
|
|
567
|
-
t.record({
|
|
568
|
-
C: t.union([t.number(0), t.nullType]),
|
|
569
|
-
}),
|
|
570
|
-
);
|
|
571
|
-
|
|
572
|
-
// ✅ Case 1: Union validation is predictable and correct
|
|
573
|
-
{
|
|
574
|
-
const UnionBA = t.union([B, A]);
|
|
575
|
-
const result = UnionBA.validate({ A: 1 });
|
|
576
|
-
|
|
577
|
-
if (t.Result.isOk(result)) {
|
|
578
|
-
console.log(result.value); // { A: 1 } <- ✅ Correct! No unexpected fields
|
|
579
|
-
console.log(A.is(result.value)); // true <- ✅ Correct
|
|
580
|
-
console.log(B.is(result.value)); // false <- ✅ Correct! B requires field B
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// ✅ Case 2: Consistent validation behavior
|
|
585
|
-
{
|
|
586
|
-
const UnionCA = t.union([C, A]);
|
|
587
|
-
const result = UnionCA.validate({ A: 1 });
|
|
588
|
-
|
|
589
|
-
if (t.Result.isOk(result)) {
|
|
590
|
-
console.log(result.value); // { A: 1 } <- ✅ Correct and consistent
|
|
591
|
-
console.log(A.is(result.value)); // true <- ✅ Correct
|
|
592
|
-
console.log(C.is(result.value)); // true <- ✅ Consistent! ts-fortress partial types allow extra fields
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
This makes ts-fortress especially valuable in large codebases where schema correctness is critical and runtime failures need to be minimized.
|
|
252
|
+
For more information, please see [this documentation](./documents/why-ts-fortress-over-zod-and-io-ts.md).
|
|
598
253
|
|
|
599
254
|
### Migration from io-ts
|
|
600
255
|
|
|
601
256
|
If you're coming from io-ts, here's how common patterns translate:
|
|
602
257
|
|
|
603
|
-
```
|
|
258
|
+
```tsx
|
|
604
259
|
// io-ts style
|
|
605
260
|
import * as t from 'io-ts';
|
|
606
261
|
|
|
607
|
-
const
|
|
262
|
+
const User = t.type({
|
|
608
263
|
id: t.string,
|
|
609
264
|
name: t.string,
|
|
610
265
|
age: t.number,
|
|
611
266
|
});
|
|
612
267
|
|
|
613
|
-
type User = t.TypeOf<typeof
|
|
268
|
+
type User = t.TypeOf<typeof User>;
|
|
269
|
+
```
|
|
614
270
|
|
|
271
|
+
```tsx
|
|
615
272
|
// ts-fortress style
|
|
616
273
|
import * as t from 'ts-fortress';
|
|
617
274
|
|
|
618
|
-
const
|
|
619
|
-
id: t.string(
|
|
620
|
-
name: t.string(
|
|
621
|
-
age: t.number(
|
|
275
|
+
const User = t.record({
|
|
276
|
+
id: t.string(),
|
|
277
|
+
name: t.string(),
|
|
278
|
+
age: t.number(20),
|
|
622
279
|
});
|
|
623
280
|
|
|
624
|
-
type User = t.TypeOf<typeof
|
|
281
|
+
type User = t.TypeOf<typeof User>;
|
|
625
282
|
```
|
|
626
283
|
|
|
627
284
|
Key differences:
|
|
628
285
|
|
|
629
|
-
- **Default values**: ts-fortress
|
|
286
|
+
- **Default values**: ts-fortress types are functions to allow for explicit default values etc.
|
|
630
287
|
- **Naming**: `record` instead of `type`, more explicit function names
|
|
631
288
|
- **Error handling**: `Result` type instead of `Either`
|
|
632
|
-
- **Branded types**: Rich collection of pre-built branded number types
|
|
633
289
|
|
|
634
290
|
## Core Concepts
|
|
635
291
|
|
|
@@ -637,7 +293,7 @@ Key differences:
|
|
|
637
293
|
|
|
638
294
|
Every validator in ts-fortress implements the `Type<A>` interface:
|
|
639
295
|
|
|
640
|
-
```
|
|
296
|
+
```tsx
|
|
641
297
|
type Type<A> = Readonly<{
|
|
642
298
|
typeName: string; // Human-readable type name
|
|
643
299
|
defaultValue: A; // Default value for this type
|
|
@@ -655,69 +311,79 @@ type Type<A> = Readonly<{
|
|
|
655
311
|
|
|
656
312
|
The `validate` method performs comprehensive validation and returns a `Result` type. **When validation succeeds, it returns the original input object (same reference)**, preserving object identity:
|
|
657
313
|
|
|
658
|
-
```
|
|
314
|
+
```tsx
|
|
659
315
|
import * as t from 'ts-fortress';
|
|
660
316
|
|
|
661
317
|
const User = t.record({
|
|
662
|
-
name: t.string(
|
|
663
|
-
age: t.number(
|
|
318
|
+
name: t.string(),
|
|
319
|
+
age: t.number(),
|
|
664
320
|
});
|
|
665
321
|
|
|
666
322
|
// Success case - returns the same object reference
|
|
667
|
-
const validData = { name: 'Alice', age: 30 };
|
|
323
|
+
const validData = { name: 'Alice', age: 30 } as const;
|
|
668
324
|
const result = User.validate(validData);
|
|
669
325
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
326
|
+
assert(t.Result.isOk(result));
|
|
327
|
+
assert(result.value === validData); // true - same reference!
|
|
328
|
+
|
|
329
|
+
assert.deepStrictEqual(result.value, { name: 'Alice', age: 30 });
|
|
674
330
|
|
|
675
331
|
// Error case - provides detailed error information
|
|
676
|
-
const invalidData = { name: 'Bob', age: 'thirty' };
|
|
332
|
+
const invalidData = { name: 'Bob', age: 'thirty' } as const;
|
|
677
333
|
const errorResult = User.validate(invalidData);
|
|
678
334
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
335
|
+
assert(t.Result.isErr(errorResult));
|
|
336
|
+
|
|
337
|
+
assert.deepStrictEqual(errorResult.value, [
|
|
338
|
+
{
|
|
339
|
+
path: ['age'],
|
|
340
|
+
actualValue: 'thirty',
|
|
341
|
+
expectedType: 'number',
|
|
342
|
+
typeName: 'number',
|
|
343
|
+
message: undefined,
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
assert.deepStrictEqual(t.validationErrorsToMessages(errorResult.value), [
|
|
348
|
+
'Expected <number> at age, got <string> type value "thirty".',
|
|
349
|
+
]);
|
|
690
350
|
```
|
|
691
351
|
|
|
692
352
|
##### `assertIs` - Type assertion with runtime checking
|
|
693
353
|
|
|
694
354
|
When using `assertIs`, you must assign it to a typed variable with an explicit type annotation due to TypeScript's limitations with assertion functions:
|
|
695
355
|
|
|
696
|
-
```
|
|
356
|
+
```tsx
|
|
697
357
|
import * as t from 'ts-fortress';
|
|
698
358
|
|
|
699
|
-
const numberType = t.number(
|
|
359
|
+
const numberType = t.number();
|
|
700
360
|
|
|
701
361
|
// ✅ Correct usage - explicit type annotation required
|
|
702
362
|
const assertIsNumber: (a: unknown) => asserts a is number = numberType.assertIs;
|
|
703
363
|
|
|
704
|
-
|
|
705
|
-
// After assertion, TypeScript knows value is a number
|
|
364
|
+
const processValue = (value: unknown): void => {
|
|
706
365
|
assertIsNumber(value);
|
|
707
|
-
|
|
708
|
-
|
|
366
|
+
|
|
367
|
+
// After assertion, TypeScript knows value is a number
|
|
368
|
+
assertType<number>(value);
|
|
369
|
+
};
|
|
709
370
|
|
|
710
371
|
try {
|
|
711
372
|
processValue(42); // Works
|
|
712
373
|
processValue('not a number'); // Throws error
|
|
713
374
|
} catch (error) {
|
|
714
|
-
|
|
375
|
+
assert.deepStrictEqual(
|
|
376
|
+
error,
|
|
377
|
+
new Error(
|
|
378
|
+
`\nExpected <number>, got <string> type value "not a number".`,
|
|
379
|
+
),
|
|
380
|
+
);
|
|
715
381
|
}
|
|
716
382
|
|
|
717
383
|
// Example with complex types
|
|
718
384
|
const User = t.record({
|
|
719
|
-
id: t.string(
|
|
720
|
-
name: t.string(
|
|
385
|
+
id: t.string(),
|
|
386
|
+
name: t.string(),
|
|
721
387
|
});
|
|
722
388
|
|
|
723
389
|
type User = t.TypeOf<typeof User>;
|
|
@@ -725,34 +391,38 @@ type User = t.TypeOf<typeof User>;
|
|
|
725
391
|
// Explicit type annotation for the assertion function
|
|
726
392
|
const assertIsUser: (a: unknown) => asserts a is User = User.assertIs;
|
|
727
393
|
|
|
728
|
-
|
|
394
|
+
const processUser = (data: unknown): void => {
|
|
729
395
|
assertIsUser(data);
|
|
396
|
+
|
|
730
397
|
// TypeScript now knows data is User type
|
|
731
|
-
|
|
732
|
-
}
|
|
398
|
+
assertType<User>(data);
|
|
399
|
+
};
|
|
733
400
|
```
|
|
734
401
|
|
|
735
402
|
##### `cast` - Type casting with validation
|
|
736
403
|
|
|
737
404
|
The `cast` method validates the input and returns it if valid, otherwise **throws an Error** with validation details:
|
|
738
405
|
|
|
739
|
-
```
|
|
406
|
+
```tsx
|
|
740
407
|
import * as t from 'ts-fortress';
|
|
741
408
|
|
|
742
409
|
const Port = t.number(8080);
|
|
743
410
|
|
|
744
|
-
|
|
411
|
+
assert(Port.cast(3000) === 3000); // 3000 is a valid number
|
|
745
412
|
|
|
746
413
|
try {
|
|
747
|
-
|
|
414
|
+
Port.cast('invalid'); // Throws Error!
|
|
748
415
|
} catch (error) {
|
|
749
|
-
|
|
416
|
+
assert.deepStrictEqual(
|
|
417
|
+
error,
|
|
418
|
+
new Error('Expected <number>, got <string> type value "invalid".'),
|
|
419
|
+
);
|
|
750
420
|
}
|
|
751
421
|
```
|
|
752
422
|
|
|
753
423
|
##### `fill` - Intelligent default value filling
|
|
754
424
|
|
|
755
|
-
The `fill` method attempts to preserve valid parts of the input while filling in missing or invalid values with defaults
|
|
425
|
+
The `fill` method attempts to preserve valid parts of the input while filling in missing or invalid values with defaults.
|
|
756
426
|
|
|
757
427
|
See [Default Values and Data Filling](#default-values-and-data-filling)
|
|
758
428
|
|
|
@@ -760,37 +430,40 @@ See [Default Values and Data Filling](#default-values-and-data-filling)
|
|
|
760
430
|
|
|
761
431
|
Every type has a `defaultValue` property that can be used for initialization:
|
|
762
432
|
|
|
763
|
-
```
|
|
433
|
+
```tsx
|
|
764
434
|
import * as t from 'ts-fortress';
|
|
765
435
|
|
|
766
436
|
const User = t.record({
|
|
767
|
-
id: t.string(
|
|
437
|
+
id: t.string(),
|
|
768
438
|
name: t.string('Guest'),
|
|
769
|
-
score: t.number(
|
|
439
|
+
score: t.number(),
|
|
770
440
|
});
|
|
771
441
|
|
|
772
442
|
type User = t.TypeOf<typeof User>;
|
|
773
443
|
|
|
774
444
|
// Use defaultValue for initialization
|
|
775
445
|
const newUser: User = { ...User.defaultValue, id: 'user-123' };
|
|
776
|
-
|
|
777
|
-
|
|
446
|
+
// This default value filling process can also be written as follows:
|
|
447
|
+
const newUser2: User = User.fill({ id: 'user-456' });
|
|
448
|
+
|
|
449
|
+
assert.deepStrictEqual(newUser, { id: 'user-123', name: 'Guest', score: 0 });
|
|
450
|
+
assert.deepStrictEqual(newUser2, { id: 'user-456', name: 'Guest', score: 0 });
|
|
778
451
|
|
|
779
452
|
// Useful for React state initialization
|
|
780
|
-
|
|
453
|
+
const UserForm = () => {
|
|
781
454
|
const [formData, setFormData] = useState<User>(User.defaultValue);
|
|
782
455
|
// ...
|
|
783
|
-
}
|
|
456
|
+
};
|
|
784
457
|
```
|
|
785
458
|
|
|
786
459
|
### Primitive Types
|
|
787
460
|
|
|
788
|
-
```
|
|
461
|
+
```tsx
|
|
789
462
|
import * as t from 'ts-fortress';
|
|
790
463
|
|
|
791
464
|
// Basic primitives
|
|
792
465
|
const stringType = t.string('default');
|
|
793
|
-
const numberType = t.number(
|
|
466
|
+
const numberType = t.number();
|
|
794
467
|
const booleanType = t.boolean(false);
|
|
795
468
|
const nullType = t.nullType;
|
|
796
469
|
const undefinedType = t.undefinedType;
|
|
@@ -800,36 +473,36 @@ const statusType = t.literal('active');
|
|
|
800
473
|
const versionType = t.literal(1);
|
|
801
474
|
|
|
802
475
|
// Arrays
|
|
803
|
-
const stringArrayType = t.array(t.string(
|
|
804
|
-
const nonEmptyArrayType = t.nonEmptyArray(t.number(
|
|
476
|
+
const stringArrayType = t.array(t.string());
|
|
477
|
+
const nonEmptyArrayType = t.nonEmptyArray(t.number());
|
|
805
478
|
|
|
806
479
|
// Tuples
|
|
807
|
-
const coordinateType = t.tuple([t.number(
|
|
480
|
+
const coordinateType = t.tuple([t.number(), t.number()]);
|
|
808
481
|
```
|
|
809
482
|
|
|
810
483
|
### Record Types
|
|
811
484
|
|
|
812
|
-
```
|
|
485
|
+
```tsx
|
|
813
486
|
import * as t from 'ts-fortress';
|
|
814
487
|
|
|
815
488
|
// Define object schemas
|
|
816
|
-
const
|
|
817
|
-
firstName: t.string(
|
|
818
|
-
lastName: t.string(
|
|
819
|
-
age: t.number(
|
|
489
|
+
const Person = t.record({
|
|
490
|
+
firstName: t.string(),
|
|
491
|
+
lastName: t.string(),
|
|
492
|
+
age: t.number(),
|
|
820
493
|
address: t.record({
|
|
821
|
-
street: t.string(
|
|
822
|
-
city: t.string(
|
|
823
|
-
zipCode: t.string(
|
|
494
|
+
street: t.string(),
|
|
495
|
+
city: t.string(),
|
|
496
|
+
zipCode: t.string(),
|
|
824
497
|
}),
|
|
825
498
|
});
|
|
826
499
|
|
|
827
|
-
type Person = t.TypeOf<typeof
|
|
500
|
+
type Person = t.TypeOf<typeof Person>;
|
|
828
501
|
|
|
829
502
|
// Optional fields
|
|
830
|
-
const
|
|
831
|
-
username: t.string(
|
|
832
|
-
bio: t.optional(t.string(
|
|
503
|
+
const UserProfile = t.record({
|
|
504
|
+
username: t.string(),
|
|
505
|
+
bio: t.optional(t.string()), // Optional field
|
|
833
506
|
settings: t.partial(
|
|
834
507
|
t.record({
|
|
835
508
|
// Partial record (all fields optional)
|
|
@@ -842,8 +515,8 @@ const UserProfileType = t.record({
|
|
|
842
515
|
// Strict validation (disallow excess properties)
|
|
843
516
|
const StrictUserType = t.record(
|
|
844
517
|
{
|
|
845
|
-
id: t.string(
|
|
846
|
-
name: t.string(
|
|
518
|
+
id: t.string(),
|
|
519
|
+
name: t.string(),
|
|
847
520
|
},
|
|
848
521
|
{
|
|
849
522
|
allowExcessProperties: false, // Reject any properties not defined in schema
|
|
@@ -852,15 +525,15 @@ const StrictUserType = t.record(
|
|
|
852
525
|
|
|
853
526
|
// Alternatively, use the strictRecord alias for cleaner syntax
|
|
854
527
|
const StrictUserTypeAlias = t.strictRecord({
|
|
855
|
-
id: t.string(
|
|
856
|
-
name: t.string(
|
|
528
|
+
id: t.string(),
|
|
529
|
+
name: t.string(),
|
|
857
530
|
});
|
|
858
531
|
|
|
859
532
|
// Permissive validation (allow excess properties) - this is the default
|
|
860
533
|
const PermissiveUserType = t.record(
|
|
861
534
|
{
|
|
862
|
-
id: t.string(
|
|
863
|
-
name: t.string(
|
|
535
|
+
id: t.string(),
|
|
536
|
+
name: t.string(),
|
|
864
537
|
},
|
|
865
538
|
{
|
|
866
539
|
allowExcessProperties: true, // Allow additional properties (default behavior)
|
|
@@ -869,17 +542,17 @@ const PermissiveUserType = t.record(
|
|
|
869
542
|
|
|
870
543
|
// Example usage - both StrictUserType and StrictUserTypeAlias behave identically
|
|
871
544
|
const strictData = { id: '123', name: 'John', extra: 'not allowed' };
|
|
872
|
-
|
|
873
|
-
|
|
545
|
+
assert(!StrictUserType.is(strictData)); // 'extra' property causes rejection
|
|
546
|
+
assert(!StrictUserTypeAlias.is(strictData)); // same as above
|
|
874
547
|
|
|
875
548
|
const permissiveData = { id: '123', name: 'John', extra: 'allowed' };
|
|
876
|
-
|
|
549
|
+
assert(PermissiveUserType.is(permissiveData)); // 'extra' property is allowed
|
|
877
550
|
|
|
878
551
|
// strictRecord provides cleaner syntax for strict validation
|
|
879
552
|
const UserSchema = t.strictRecord({
|
|
880
|
-
name: t.string(
|
|
881
|
-
email: t.string(
|
|
882
|
-
age: t.number(
|
|
553
|
+
name: t.string(),
|
|
554
|
+
email: t.string(),
|
|
555
|
+
age: t.number(),
|
|
883
556
|
});
|
|
884
557
|
|
|
885
558
|
// Validation examples
|
|
@@ -892,27 +565,173 @@ UserSchema.is({
|
|
|
892
565
|
}); // ❌ false - excess property
|
|
893
566
|
```
|
|
894
567
|
|
|
568
|
+
### Refined Types
|
|
569
|
+
|
|
570
|
+
ts-fortress provides the `refine` function to create refined types with custom validation logic while leveraging existing base types:
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
import * as t from 'ts-fortress';
|
|
574
|
+
|
|
575
|
+
// Create refined types
|
|
576
|
+
const Uuid = t.refine({
|
|
577
|
+
baseType: t.string(),
|
|
578
|
+
// Define custom validation logic
|
|
579
|
+
is: (value: string): value is string =>
|
|
580
|
+
/^[\da-f]{8}-[\da-f]{4}-[0-5][\da-f]{3}-[089ab][\da-f]{3}-[\da-f]{12}$/iu.test(
|
|
581
|
+
value,
|
|
582
|
+
),
|
|
583
|
+
defaultValue: '00000000-1111-2222-3333-444444444444',
|
|
584
|
+
typeName: 'Uuid',
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
type Uuid = t.TypeOf<typeof Uuid>; // string (with runtime validation)
|
|
588
|
+
|
|
589
|
+
const PositiveNumber = t.refine({
|
|
590
|
+
baseType: t.number(1),
|
|
591
|
+
is: (value: number): value is number => value > 0,
|
|
592
|
+
defaultValue: 1,
|
|
593
|
+
typeName: 'PositiveNumber',
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
type PositiveNumber = t.TypeOf<typeof PositiveNumber>; // number (with runtime validation)
|
|
597
|
+
|
|
598
|
+
const EvenNumber = t.refine({
|
|
599
|
+
baseType: t.number(),
|
|
600
|
+
is: (value: number): value is number => value % 2 === 0,
|
|
601
|
+
defaultValue: 0,
|
|
602
|
+
typeName: 'EvenNumber',
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Usage in validation
|
|
606
|
+
const uuidResult = Uuid.validate('6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b');
|
|
607
|
+
|
|
608
|
+
assert(t.Result.isOk(uuidResult));
|
|
609
|
+
|
|
610
|
+
if (t.Result.isOk(uuidResult)) {
|
|
611
|
+
const validUuid = uuidResult.value; // string, guaranteed to be valid Uuid format
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const positiveResult = PositiveNumber.validate(42);
|
|
615
|
+
|
|
616
|
+
assert(t.Result.isOk(positiveResult));
|
|
617
|
+
|
|
618
|
+
if (t.Result.isOk(positiveResult)) {
|
|
619
|
+
const positiveNum = positiveResult.value; // number, guaranteed to be > 0
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Invalid cases
|
|
623
|
+
assert(!Uuid.is('invalid-uuid'));
|
|
624
|
+
assert(!PositiveNumber.is(-5));
|
|
625
|
+
assert(!EvenNumber.is(7));
|
|
626
|
+
|
|
627
|
+
// Use in record schemas
|
|
628
|
+
const UserProfile = t.record({
|
|
629
|
+
id: Uuid, // refined uuid validation
|
|
630
|
+
score: PositiveNumber, // must be positive
|
|
631
|
+
level: EvenNumber, // must be even
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
type UserProfile = t.TypeOf<typeof UserProfile>;
|
|
635
|
+
|
|
636
|
+
// The refined types maintain their validation in composite types
|
|
637
|
+
const userData = {
|
|
638
|
+
id: '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b', // ✅ valid uuid format
|
|
639
|
+
score: 85, // ✅ positive number
|
|
640
|
+
level: 4, // ✅ even number
|
|
641
|
+
} as const satisfies UserProfile;
|
|
642
|
+
|
|
643
|
+
assert(UserProfile.is(userData));
|
|
644
|
+
|
|
645
|
+
const invalidData = {
|
|
646
|
+
id: 'user123', // ❌ invalid uuid format
|
|
647
|
+
score: -10, // ❌ negative number
|
|
648
|
+
level: 3, // ❌ odd number
|
|
649
|
+
} as const;
|
|
650
|
+
|
|
651
|
+
const result = UserProfile.validate(invalidData);
|
|
652
|
+
|
|
653
|
+
assert(t.Result.isErr(result));
|
|
654
|
+
|
|
655
|
+
assert.deepStrictEqual(
|
|
656
|
+
t.validationErrorsToMessages(
|
|
657
|
+
result.value satisfies readonly t.ValidationError[],
|
|
658
|
+
),
|
|
659
|
+
[
|
|
660
|
+
'Expected <Uuid> at id, got <string> type value "user123".',
|
|
661
|
+
'Expected <PositiveNumber> at score, got <number> type value `-10`.',
|
|
662
|
+
'Expected <EvenNumber> at level, got <number> type value `3`.',
|
|
663
|
+
],
|
|
664
|
+
);
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
#### Key Benefits of Refined Types
|
|
668
|
+
|
|
669
|
+
- **Composable validation**: Build on existing base types while adding custom constraints
|
|
670
|
+
- **Type safety**: TypeScript types reflect the refined constraints at compile time
|
|
671
|
+
- **Clear error messages**: Validation errors clearly indicate which refinement failed
|
|
672
|
+
- **Reusable logic**: Define validation logic once and reuse across multiple schemas
|
|
673
|
+
- **Performance**: Leverages base type validation before applying custom refinements
|
|
674
|
+
|
|
675
|
+
#### Common Use Cases
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
import * as t from 'ts-fortress';
|
|
679
|
+
|
|
680
|
+
// Domain-specific string types
|
|
681
|
+
const PhoneNumber = t.refine({
|
|
682
|
+
baseType: t.string(),
|
|
683
|
+
is: (s): s is string => /^\+?[\d\s()-]+$/u.test(s),
|
|
684
|
+
defaultValue: '+1234567890',
|
|
685
|
+
typeName: 'PhoneNumber',
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const ZipCode = t.refine({
|
|
689
|
+
baseType: t.string(),
|
|
690
|
+
is: (s): s is string => /^\d{5}(-\d{4})?$/u.test(s),
|
|
691
|
+
defaultValue: '12345',
|
|
692
|
+
typeName: 'ZipCode',
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Constrained numeric types
|
|
696
|
+
const Percentage = t.refine({
|
|
697
|
+
baseType: t.number(),
|
|
698
|
+
is: (n): n is number => 0 <= n && n <= 100,
|
|
699
|
+
defaultValue: 0,
|
|
700
|
+
typeName: 'Percentage',
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const Port = t.refine({
|
|
704
|
+
baseType: t.number(3000),
|
|
705
|
+
is: (n): n is number => Number.isInteger(n) && 1 <= n && n <= 65_535,
|
|
706
|
+
defaultValue: 3000,
|
|
707
|
+
typeName: 'Port',
|
|
708
|
+
});
|
|
709
|
+
```
|
|
710
|
+
|
|
895
711
|
### Branded Types
|
|
896
712
|
|
|
897
713
|
ts-fortress provides extensive support for branded types to create domain-specific validation:
|
|
898
714
|
|
|
899
|
-
```
|
|
715
|
+
```tsx
|
|
900
716
|
import * as t from 'ts-fortress';
|
|
901
717
|
|
|
902
718
|
// Simple branded types
|
|
903
|
-
const UserId = t.
|
|
904
|
-
const Weight = t.
|
|
719
|
+
const UserId = t.brandedString({ typeName: 'UserId', defaultValue: '' });
|
|
720
|
+
const Weight = t.brandedNumber({ typeName: 'Weight', defaultValue: 0 });
|
|
905
721
|
|
|
906
722
|
type UserId = t.TypeOf<typeof UserId>; // Brand<string, 'UserId'>
|
|
907
723
|
type Weight = t.TypeOf<typeof Weight>; // Brand<number, 'Weight'>
|
|
908
724
|
|
|
909
725
|
// Rich number validation types
|
|
910
|
-
const PositiveInt = t.positiveInt(1);
|
|
911
|
-
const SafeInt = t.safeInt(0);
|
|
912
|
-
const UInt16 = t.uint16(0);
|
|
726
|
+
const PositiveInt = t.positiveInt(t.asPositiveInt(1));
|
|
727
|
+
const SafeInt = t.safeInt(t.asSafeInt(0));
|
|
728
|
+
const UInt16 = t.uint16(t.asUint16(0));
|
|
913
729
|
|
|
914
730
|
// Usage
|
|
915
731
|
const userIdResult = UserId.validate('user_123');
|
|
732
|
+
|
|
733
|
+
assert(t.Result.isOk(userIdResult));
|
|
734
|
+
|
|
916
735
|
if (t.Result.isOk(userIdResult)) {
|
|
917
736
|
const id: UserId = userIdResult.value;
|
|
918
737
|
}
|
|
@@ -920,38 +739,46 @@ if (t.Result.isOk(userIdResult)) {
|
|
|
920
739
|
|
|
921
740
|
### Union and Intersection Types
|
|
922
741
|
|
|
923
|
-
```
|
|
742
|
+
```tsx
|
|
924
743
|
import * as t from 'ts-fortress';
|
|
925
744
|
|
|
926
745
|
// Union types
|
|
927
|
-
const IdType = t.union([t.string(
|
|
746
|
+
const IdType = t.union([t.string(), t.number()]);
|
|
928
747
|
|
|
929
748
|
// Intersection types
|
|
930
|
-
const TimestampedType = t.intersection(
|
|
931
|
-
|
|
749
|
+
const TimestampedType = t.intersection(
|
|
750
|
+
[
|
|
751
|
+
t.record({ data: t.string() }),
|
|
752
|
+
t.record({
|
|
753
|
+
createdAt: t.number(Date.now()),
|
|
754
|
+
updatedAt: t.number(Date.now()),
|
|
755
|
+
}),
|
|
756
|
+
],
|
|
932
757
|
t.record({
|
|
758
|
+
data: t.string(),
|
|
933
759
|
createdAt: t.number(Date.now()),
|
|
934
760
|
updatedAt: t.number(Date.now()),
|
|
935
761
|
}),
|
|
936
|
-
|
|
762
|
+
);
|
|
937
763
|
|
|
938
764
|
// Merge records (similar to intersection but more specific)
|
|
939
765
|
const ExtendedUserType = t.mergeRecords([
|
|
940
766
|
PersonType,
|
|
941
767
|
t.record({
|
|
942
|
-
id: t.string(
|
|
943
|
-
email: t.string(
|
|
768
|
+
id: t.string(),
|
|
769
|
+
email: t.string(),
|
|
944
770
|
}),
|
|
945
771
|
]);
|
|
946
772
|
```
|
|
947
773
|
|
|
948
774
|
### Enums
|
|
949
775
|
|
|
950
|
-
```
|
|
776
|
+
```tsx
|
|
951
777
|
import * as t from 'ts-fortress';
|
|
952
778
|
|
|
953
779
|
// String enums
|
|
954
780
|
const ColorEnum = t.enumType(['red', 'green', 'blue']);
|
|
781
|
+
|
|
955
782
|
type Color = t.TypeOf<typeof ColorEnum>; // 'red' | 'green' | 'blue'
|
|
956
783
|
|
|
957
784
|
// Numeric ranges
|
|
@@ -960,51 +787,77 @@ const DiceRoll = t.uintRange({
|
|
|
960
787
|
end: 7,
|
|
961
788
|
defaultValue: 1,
|
|
962
789
|
}); // integers from 1 to 6
|
|
790
|
+
|
|
963
791
|
type DiceRoll = t.TypeOf<typeof DiceRoll>; // 1 | 2 | 3 | 4 | 5 | 6
|
|
964
792
|
```
|
|
965
793
|
|
|
794
|
+
Tips: It is often better to use `uintRange` instead of `enumType` when possible, because `enumType` stores a Set of the sizes of its members as data, while `uintRange` only stores the range, resulting in smaller memory usage.
|
|
795
|
+
|
|
966
796
|
## Error Handling
|
|
967
797
|
|
|
968
798
|
ts-fortress uses `Result<T, readonly ValidationError[]>` for structured error handling with detailed error information:
|
|
969
799
|
|
|
970
|
-
```
|
|
800
|
+
```tsx
|
|
971
801
|
import * as t from 'ts-fortress';
|
|
972
802
|
|
|
973
|
-
const
|
|
974
|
-
name: t.string(
|
|
975
|
-
age: t.number(
|
|
803
|
+
const User = t.record({
|
|
804
|
+
name: t.string(),
|
|
805
|
+
age: t.number(),
|
|
976
806
|
});
|
|
977
807
|
|
|
808
|
+
type User = t.TypeOf<typeof User>;
|
|
809
|
+
|
|
978
810
|
const invalidData = { name: 123, age: 'not a number' };
|
|
979
811
|
|
|
980
|
-
const result =
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
812
|
+
const result = User.validate(invalidData);
|
|
813
|
+
|
|
814
|
+
assert(t.Result.isErr(result));
|
|
815
|
+
|
|
816
|
+
// result.value is an array of ValidationError objects
|
|
817
|
+
assert.deepStrictEqual(result.value, [
|
|
818
|
+
{
|
|
819
|
+
actualValue: 123,
|
|
820
|
+
expectedType: 'string',
|
|
821
|
+
message: undefined,
|
|
822
|
+
path: ['name'],
|
|
823
|
+
typeName: 'string',
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
actualValue: 'not a number',
|
|
827
|
+
expectedType: 'number',
|
|
828
|
+
message: undefined,
|
|
829
|
+
path: ['age'],
|
|
830
|
+
typeName: 'number',
|
|
831
|
+
},
|
|
832
|
+
] satisfies t.ValidationError[]);
|
|
833
|
+
|
|
834
|
+
// Convert to string messages
|
|
835
|
+
const messages = t.validationErrorsToMessages(result.value);
|
|
836
|
+
|
|
837
|
+
assert.deepStrictEqual(messages, [
|
|
838
|
+
'Expected <string> at name, got <number> type value `123`.',
|
|
839
|
+
'Expected <number> at age, got <string> type value "not a number".',
|
|
840
|
+
]);
|
|
841
|
+
|
|
842
|
+
const assertIsUser: (a: unknown) => asserts a is User = User.assertIs;
|
|
995
843
|
|
|
996
844
|
// Using assertions (throws on invalid data)
|
|
997
845
|
try {
|
|
998
|
-
|
|
846
|
+
assertIsUser(invalidData);
|
|
999
847
|
} catch (error) {
|
|
1000
|
-
|
|
848
|
+
assert.deepStrictEqual(
|
|
849
|
+
error,
|
|
850
|
+
new Error(
|
|
851
|
+
'\nExpected <string> at name, got <number> type value `123`.,\nExpected <number> at age, got <string> type value "not a number".',
|
|
852
|
+
),
|
|
853
|
+
);
|
|
1001
854
|
}
|
|
1002
855
|
|
|
1003
856
|
// Excess property validation example
|
|
1004
857
|
const StrictType = t.record(
|
|
1005
858
|
{
|
|
1006
|
-
name: t.string(
|
|
1007
|
-
age: t.number(
|
|
859
|
+
name: t.string(),
|
|
860
|
+
age: t.number(),
|
|
1008
861
|
},
|
|
1009
862
|
{
|
|
1010
863
|
allowExcessProperties: false,
|
|
@@ -1014,23 +867,24 @@ const StrictType = t.record(
|
|
|
1014
867
|
const dataWithExcess = { name: 'John', age: 30, extra: 'not allowed' };
|
|
1015
868
|
const strictResult = StrictType.validate(dataWithExcess);
|
|
1016
869
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
}
|
|
870
|
+
assert(t.Result.isErr(strictResult));
|
|
871
|
+
|
|
872
|
+
assert.deepStrictEqual(strictResult.value, [
|
|
873
|
+
{
|
|
874
|
+
path: ['extra'],
|
|
875
|
+
actualValue: 'not allowed',
|
|
876
|
+
expectedType: '{ name: string, age: number }',
|
|
877
|
+
message: 'Excess property "extra" is not allowed',
|
|
878
|
+
typeName: '{ name: string, age: number }',
|
|
879
|
+
},
|
|
880
|
+
]);
|
|
1027
881
|
```
|
|
1028
882
|
|
|
1029
883
|
### ValidationError Structure
|
|
1030
884
|
|
|
1031
885
|
Each validation error provides detailed information:
|
|
1032
886
|
|
|
1033
|
-
```
|
|
887
|
+
```tsx
|
|
1034
888
|
type ValidationError = Readonly<{
|
|
1035
889
|
path: readonly string[];
|
|
1036
890
|
actualValue: unknown; // The actual value that failed validation
|
|
@@ -1078,8 +932,9 @@ type ValidationError = Readonly<{
|
|
|
1078
932
|
### Refinement
|
|
1079
933
|
|
|
1080
934
|
- `t.refine({ baseType, is, defaultValue })` - Refine `baseType` by `is` function
|
|
1081
|
-
- `t.
|
|
1082
|
-
- `t.
|
|
935
|
+
- `t.brand({ baseType, is, defaultValue, brandKeys, brandFalseKeys?, typeName? })` - Refine `baseType` by `is` function with brand typing
|
|
936
|
+
- `t.brandedString({ typeName, defaultValue, is? })` - String branding
|
|
937
|
+
- `t.brandedNumber({ typeName, defaultValue, is? })` - Number branding
|
|
1083
938
|
- Number types: `t.int()`, `t.safeInt()`, `t.positiveInt()`, `t.uint16()`, etc.
|
|
1084
939
|
|
|
1085
940
|
### Utilities
|