ts-fortress 2.0.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -1
- package/dist/index.mjs +1 -1
- package/dist/record/index.mjs +1 -1
- package/dist/record/key-value-record.d.mts +1 -1
- package/dist/record/key-value-record.d.mts.map +1 -1
- package/dist/record/record.d.mts +31 -5
- package/dist/record/record.d.mts.map +1 -1
- package/dist/record/record.mjs +42 -4
- package/dist/record/record.mjs.map +1 -1
- package/package.json +1 -1
- package/src/array/array-of-length.test.mts +16 -1
- package/src/array/array.test.mts +16 -1
- package/src/array/non-empty-array.test.mts +16 -1
- package/src/array/tuple.test.mts +18 -1
- package/src/compose/intersection.test.mts +43 -1
- package/src/compose/merge-records.test.mts +16 -1
- package/src/compose/union.test.mts +16 -1
- package/src/enum/enum.test.mts +16 -1
- package/src/enum/uint-range.test.mts +14 -1
- package/src/primitives/null.test.mts +170 -0
- package/src/primitives/number.test.mts +284 -0
- package/src/primitives/string.test.mts +341 -0
- package/src/primitives/undefined.test.mts +179 -0
- package/src/record/key-value-record.mts +4 -4
- package/src/record/key-value-record.test.mts +28 -2
- package/src/record/keyof.test.mts +12 -0
- package/src/record/partial.test.mts +18 -0
- package/src/record/record-allow-excess-properties.test.mts +180 -0
- package/src/record/record-strict-composition-simple.test.mts +134 -0
- package/src/record/record-strict-composition.test.mts +403 -0
- package/src/record/record.mts +92 -31
- package/src/record/record.test.mts +71 -1
- package/src/record/strict-record.test.mts +192 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Result } from 'ts-data-forge';
|
|
2
|
+
import { mergeRecords } from '../compose/index.mjs';
|
|
3
|
+
import { number, string } from '../primitives/index.mjs';
|
|
4
|
+
import { omit } from './omit.mjs';
|
|
5
|
+
import { partial } from './partial.mjs';
|
|
6
|
+
import { pick } from './pick.mjs';
|
|
7
|
+
import { record } from './record.mjs';
|
|
8
|
+
|
|
9
|
+
describe('record strict composition tests', () => {
|
|
10
|
+
// Base strict record type
|
|
11
|
+
const strictBaseRecord = record(
|
|
12
|
+
{
|
|
13
|
+
id: string(''),
|
|
14
|
+
name: string(''),
|
|
15
|
+
age: number(0),
|
|
16
|
+
email: string(''),
|
|
17
|
+
},
|
|
18
|
+
{ allowExcessProperties: false },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
describe('pick with strict record', () => {
|
|
22
|
+
const pickedType = pick(strictBaseRecord, ['id', 'name']);
|
|
23
|
+
|
|
24
|
+
test('accepts valid data without excess properties', () => {
|
|
25
|
+
const validData = { id: '123', name: 'John' };
|
|
26
|
+
|
|
27
|
+
expect(pickedType.is(validData)).toBe(true);
|
|
28
|
+
|
|
29
|
+
const result = pickedType.validate(validData);
|
|
30
|
+
expect(Result.isOk(result)).toBe(true);
|
|
31
|
+
|
|
32
|
+
if (Result.isOk(result)) {
|
|
33
|
+
// pick validates by calling the original record's validate with filled data
|
|
34
|
+
// so it includes all original fields with defaults
|
|
35
|
+
expect(result.value).toStrictEqual({
|
|
36
|
+
id: '123',
|
|
37
|
+
name: 'John',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('rejects data with excess properties (inherits strict behavior)', () => {
|
|
43
|
+
const dataWithExcess = { id: '123', name: 'John', extra: 'not allowed' };
|
|
44
|
+
|
|
45
|
+
expect(pickedType.is(dataWithExcess)).toBe(false);
|
|
46
|
+
|
|
47
|
+
const result = pickedType.validate(dataWithExcess);
|
|
48
|
+
expect(Result.isErr(result)).toBe(true);
|
|
49
|
+
|
|
50
|
+
if (Result.isErr(result)) {
|
|
51
|
+
expect(result.value).toHaveLength(1);
|
|
52
|
+
expect(result.value[0]).toStrictEqual({
|
|
53
|
+
path: ['extra'],
|
|
54
|
+
actualValue: 'not allowed',
|
|
55
|
+
typeName: '{ id: string, name: string, age: number, email: string }',
|
|
56
|
+
expectedType:
|
|
57
|
+
'{ id: string, name: string, age: number, email: string }',
|
|
58
|
+
message: 'Excess property "extra" is not allowed',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('rejects missing required properties', () => {
|
|
64
|
+
const incompleteData = { id: '123' }; // missing 'name'
|
|
65
|
+
|
|
66
|
+
const result = pickedType.validate(incompleteData);
|
|
67
|
+
expect(Result.isErr(result)).toBe(true);
|
|
68
|
+
|
|
69
|
+
if (Result.isErr(result)) {
|
|
70
|
+
expect(result.value).toHaveLength(1);
|
|
71
|
+
expect(result.value[0]?.message).toBe('Missing required key "name"');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('omit with strict record', () => {
|
|
77
|
+
const omittedType = omit(strictBaseRecord, ['age', 'email']);
|
|
78
|
+
|
|
79
|
+
test('accepts valid data without excess properties', () => {
|
|
80
|
+
const validData = { id: '123', name: 'John' };
|
|
81
|
+
|
|
82
|
+
expect(omittedType.is(validData)).toBe(true);
|
|
83
|
+
|
|
84
|
+
const result = omittedType.validate(validData);
|
|
85
|
+
expect(Result.isOk(result)).toBe(true);
|
|
86
|
+
|
|
87
|
+
if (Result.isOk(result)) {
|
|
88
|
+
// omit validates by calling the original record's validate with filled data
|
|
89
|
+
// so it includes all original fields with defaults
|
|
90
|
+
expect(result.value).toStrictEqual({
|
|
91
|
+
id: '123',
|
|
92
|
+
name: 'John',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('rejects data with excess properties (inherits strict behavior)', () => {
|
|
98
|
+
const dataWithExcess = { id: '123', name: 'John', extra: 'not allowed' };
|
|
99
|
+
|
|
100
|
+
expect(omittedType.is(dataWithExcess)).toBe(false);
|
|
101
|
+
|
|
102
|
+
const result = omittedType.validate(dataWithExcess);
|
|
103
|
+
expect(Result.isErr(result)).toBe(true);
|
|
104
|
+
|
|
105
|
+
if (Result.isErr(result)) {
|
|
106
|
+
expect(result.value).toHaveLength(1);
|
|
107
|
+
expect(result.value[0]).toStrictEqual({
|
|
108
|
+
path: ['extra'],
|
|
109
|
+
actualValue: 'not allowed',
|
|
110
|
+
typeName: '{ id: string, name: string, age: number, email: string }',
|
|
111
|
+
expectedType:
|
|
112
|
+
'{ id: string, name: string, age: number, email: string }',
|
|
113
|
+
message: 'Excess property "extra" is not allowed',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('accepts omitted properties when provided (they are filled by original record)', () => {
|
|
119
|
+
const dataWithOmittedProperty = { id: '123', name: 'John', age: 25 }; // 'age' was omitted but provided
|
|
120
|
+
|
|
121
|
+
const result = omittedType.validate(dataWithOmittedProperty);
|
|
122
|
+
expect(Result.isOk(result)).toBe(true); // omit doesn't actually exclude these from validation
|
|
123
|
+
|
|
124
|
+
if (Result.isOk(result)) {
|
|
125
|
+
expect(result.value).toStrictEqual({
|
|
126
|
+
id: '123',
|
|
127
|
+
name: 'John',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('partial with strict record', () => {
|
|
134
|
+
const partialType = partial(strictBaseRecord);
|
|
135
|
+
|
|
136
|
+
test('accepts valid data without excess properties', () => {
|
|
137
|
+
const validData = { id: '123', name: 'John' };
|
|
138
|
+
|
|
139
|
+
expect(partialType.is(validData)).toBe(true);
|
|
140
|
+
|
|
141
|
+
const result = partialType.validate(validData);
|
|
142
|
+
expect(Result.isOk(result)).toBe(true);
|
|
143
|
+
|
|
144
|
+
if (Result.isOk(result)) {
|
|
145
|
+
// partial now only returns the provided fields
|
|
146
|
+
expect(result.value).toStrictEqual({
|
|
147
|
+
id: '123',
|
|
148
|
+
name: 'John',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('accepts empty object (all fields optional)', () => {
|
|
154
|
+
const emptyData = {};
|
|
155
|
+
|
|
156
|
+
expect(partialType.is(emptyData)).toBe(true);
|
|
157
|
+
|
|
158
|
+
const result = partialType.validate(emptyData);
|
|
159
|
+
expect(Result.isOk(result)).toBe(true);
|
|
160
|
+
|
|
161
|
+
if (Result.isOk(result)) {
|
|
162
|
+
// partial with empty object returns empty object
|
|
163
|
+
expect(result.value).toStrictEqual({});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('rejects data with excess properties (inherits strict behavior)', () => {
|
|
168
|
+
const dataWithExcess = { id: '123', name: 'John', extra: 'not allowed' };
|
|
169
|
+
|
|
170
|
+
expect(partialType.is(dataWithExcess)).toBe(false);
|
|
171
|
+
|
|
172
|
+
const result = partialType.validate(dataWithExcess);
|
|
173
|
+
expect(Result.isErr(result)).toBe(true);
|
|
174
|
+
|
|
175
|
+
if (Result.isErr(result)) {
|
|
176
|
+
expect(result.value).toHaveLength(1);
|
|
177
|
+
expect(result.value[0]).toStrictEqual({
|
|
178
|
+
path: ['extra'],
|
|
179
|
+
actualValue: 'not allowed',
|
|
180
|
+
typeName: '{ id: string, name: string, age: number, email: string }',
|
|
181
|
+
expectedType:
|
|
182
|
+
'{ id: string, name: string, age: number, email: string }',
|
|
183
|
+
message: 'Excess property "extra" is not allowed',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('partially partial with specific keys', () => {
|
|
189
|
+
const partiallyPartialType = partial(strictBaseRecord, {
|
|
190
|
+
keysToBeOptional: ['age', 'email'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const validData = { id: '123', name: 'John' }; // required fields provided, optional fields omitted
|
|
194
|
+
|
|
195
|
+
expect(partiallyPartialType.is(validData)).toBe(true);
|
|
196
|
+
|
|
197
|
+
const result = partiallyPartialType.validate(validData);
|
|
198
|
+
expect(Result.isOk(result)).toBe(true);
|
|
199
|
+
|
|
200
|
+
if (Result.isOk(result)) {
|
|
201
|
+
// partially partial now only returns the provided fields
|
|
202
|
+
expect(result.value).toStrictEqual({
|
|
203
|
+
id: '123',
|
|
204
|
+
name: 'John',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('partially partial rejects missing required fields', () => {
|
|
210
|
+
const partiallyPartialType = partial(strictBaseRecord, {
|
|
211
|
+
keysToBeOptional: ['age', 'email'],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const incompleteData = { id: '123' }; // missing required 'name'
|
|
215
|
+
|
|
216
|
+
const result = partiallyPartialType.validate(incompleteData);
|
|
217
|
+
expect(Result.isErr(result)).toBe(true);
|
|
218
|
+
|
|
219
|
+
if (Result.isErr(result)) {
|
|
220
|
+
expect(result.value).toHaveLength(1);
|
|
221
|
+
expect(result.value[0]?.message).toBe('Missing required key "name"');
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('mergeRecords with strict records', () => {
|
|
227
|
+
const strictRecord1 = record(
|
|
228
|
+
{
|
|
229
|
+
id: string(''),
|
|
230
|
+
name: string(''),
|
|
231
|
+
},
|
|
232
|
+
{ allowExcessProperties: false },
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const strictRecord2 = record(
|
|
236
|
+
{
|
|
237
|
+
age: number(0),
|
|
238
|
+
email: string(''),
|
|
239
|
+
},
|
|
240
|
+
{ allowExcessProperties: false },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const mergedType = mergeRecords([strictRecord1, strictRecord2]);
|
|
244
|
+
|
|
245
|
+
test('rejects valid data when both records cannot validate it (mergeRecords validates against each type separately)', () => {
|
|
246
|
+
const validData = {
|
|
247
|
+
id: '123',
|
|
248
|
+
name: 'John',
|
|
249
|
+
age: 25,
|
|
250
|
+
email: 'john@example.com',
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// mergeRecords validates against each record separately
|
|
254
|
+
// strictRecord1 will reject because it doesn't know about age/email
|
|
255
|
+
// strictRecord2 will reject because it doesn't know about id/name
|
|
256
|
+
expect(mergedType.is(validData)).toBe(false);
|
|
257
|
+
|
|
258
|
+
const result = mergedType.validate(validData);
|
|
259
|
+
expect(Result.isErr(result)).toBe(true);
|
|
260
|
+
|
|
261
|
+
if (Result.isErr(result)) {
|
|
262
|
+
// Both records should produce errors for unknown fields
|
|
263
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('rejects data with excess properties (both records are strict)', () => {
|
|
268
|
+
const dataWithExcess = {
|
|
269
|
+
id: '123',
|
|
270
|
+
name: 'John',
|
|
271
|
+
age: 25,
|
|
272
|
+
email: 'john@example.com',
|
|
273
|
+
extra: 'not allowed',
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
expect(mergedType.is(dataWithExcess)).toBe(false);
|
|
277
|
+
|
|
278
|
+
const result = mergedType.validate(dataWithExcess);
|
|
279
|
+
expect(Result.isErr(result)).toBe(true);
|
|
280
|
+
|
|
281
|
+
if (Result.isErr(result)) {
|
|
282
|
+
// Both records should reject the excess property
|
|
283
|
+
expect(result.value.length).toBeGreaterThanOrEqual(1);
|
|
284
|
+
const excessErrors = result.value.filter(
|
|
285
|
+
(error) =>
|
|
286
|
+
error.message?.includes(
|
|
287
|
+
'Excess property "extra" is not allowed',
|
|
288
|
+
) === true,
|
|
289
|
+
);
|
|
290
|
+
expect(excessErrors.length).toBeGreaterThanOrEqual(1);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('rejects missing required properties from any merged record', () => {
|
|
295
|
+
const incompleteData = { id: '123', name: 'John', age: 25 }; // missing 'email'
|
|
296
|
+
|
|
297
|
+
const result = mergedType.validate(incompleteData);
|
|
298
|
+
expect(Result.isErr(result)).toBe(true);
|
|
299
|
+
|
|
300
|
+
if (Result.isErr(result)) {
|
|
301
|
+
const missingEmailError = result.value.find(
|
|
302
|
+
(error) =>
|
|
303
|
+
error.message?.includes('Missing required key "email"') === true,
|
|
304
|
+
);
|
|
305
|
+
expect(missingEmailError).toBeDefined();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('mixed strict and permissive records', () => {
|
|
310
|
+
const permissiveRecord = record({
|
|
311
|
+
status: string(''),
|
|
312
|
+
metadata: string(''),
|
|
313
|
+
}); // allowExcessProperties defaults to true
|
|
314
|
+
|
|
315
|
+
const mixedMergedType = mergeRecords([strictRecord1, permissiveRecord]);
|
|
316
|
+
|
|
317
|
+
const dataWithExcess = {
|
|
318
|
+
id: '123',
|
|
319
|
+
name: 'John',
|
|
320
|
+
status: 'active',
|
|
321
|
+
metadata: 'some data',
|
|
322
|
+
extra: 'allowed by permissive record',
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const result = mixedMergedType.validate(dataWithExcess);
|
|
326
|
+
|
|
327
|
+
// Since one record is permissive, the merged type should accept excess properties
|
|
328
|
+
// But this depends on implementation - in ts-fortress, each record validates independently
|
|
329
|
+
// The strict record will reject, the permissive record will accept
|
|
330
|
+
expect(Result.isErr(result)).toBe(true); // Strict record rejects excess property
|
|
331
|
+
|
|
332
|
+
if (Result.isErr(result)) {
|
|
333
|
+
const excessErrors = result.value.filter(
|
|
334
|
+
(error) =>
|
|
335
|
+
error.message?.includes(
|
|
336
|
+
'Excess property "extra" is not allowed',
|
|
337
|
+
) === true,
|
|
338
|
+
);
|
|
339
|
+
expect(excessErrors.length).toBeGreaterThanOrEqual(1);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('nested strict compositions', () => {
|
|
345
|
+
test('pick of partial of strict record', () => {
|
|
346
|
+
const partialType = partial(strictBaseRecord);
|
|
347
|
+
const pickedPartialType = pick(partialType, ['id', 'name']);
|
|
348
|
+
|
|
349
|
+
const validData = { id: '123', name: 'John' };
|
|
350
|
+
|
|
351
|
+
expect(pickedPartialType.is(validData)).toBe(true);
|
|
352
|
+
|
|
353
|
+
const result = pickedPartialType.validate(validData);
|
|
354
|
+
expect(Result.isOk(result)).toBe(true);
|
|
355
|
+
|
|
356
|
+
if (Result.isOk(result)) {
|
|
357
|
+
// pick calls the partial type's validate, which fills missing fields
|
|
358
|
+
expect(result.value).toStrictEqual({
|
|
359
|
+
id: '123',
|
|
360
|
+
name: 'John',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('partial of pick of strict record', () => {
|
|
366
|
+
const pickedType = pick(strictBaseRecord, ['id', 'name']);
|
|
367
|
+
const partialPickedType = partial(pickedType);
|
|
368
|
+
|
|
369
|
+
const validData = { id: '123' }; // partial allows missing 'name'
|
|
370
|
+
|
|
371
|
+
expect(partialPickedType.is(validData)).toBe(true);
|
|
372
|
+
|
|
373
|
+
const result = partialPickedType.validate(validData);
|
|
374
|
+
expect(Result.isOk(result)).toBe(true);
|
|
375
|
+
|
|
376
|
+
if (Result.isOk(result)) {
|
|
377
|
+
// partial of pick fills with the original record's defaults
|
|
378
|
+
expect(result.value).toStrictEqual({
|
|
379
|
+
id: '123',
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('nested compositions reject excess properties', () => {
|
|
385
|
+
const partialType = partial(strictBaseRecord);
|
|
386
|
+
const pickedPartialType = pick(partialType, ['id', 'name']);
|
|
387
|
+
|
|
388
|
+
const dataWithExcess = { id: '123', name: 'John', extra: 'not allowed' };
|
|
389
|
+
|
|
390
|
+
expect(pickedPartialType.is(dataWithExcess)).toBe(false);
|
|
391
|
+
|
|
392
|
+
const result = pickedPartialType.validate(dataWithExcess);
|
|
393
|
+
expect(Result.isErr(result)).toBe(true);
|
|
394
|
+
|
|
395
|
+
if (Result.isErr(result)) {
|
|
396
|
+
expect(result.value).toHaveLength(1);
|
|
397
|
+
expect(result.value[0]?.message).toBe(
|
|
398
|
+
'Excess property "extra" is not allowed',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
package/src/record/record.mts
CHANGED
|
@@ -8,22 +8,29 @@ import {
|
|
|
8
8
|
type ValidationErrorWithMessage,
|
|
9
9
|
} from '../validation-error.mjs';
|
|
10
10
|
|
|
11
|
-
export const record = <const R extends
|
|
11
|
+
export const record = <const R extends ReadonlyRecord<string, Type<unknown>>>(
|
|
12
12
|
source: R,
|
|
13
13
|
options?: Partial<
|
|
14
14
|
Readonly<{
|
|
15
15
|
typeName: string;
|
|
16
|
+
|
|
17
|
+
/** @default true */
|
|
18
|
+
allowExcessProperties: boolean;
|
|
16
19
|
}>
|
|
17
20
|
>,
|
|
18
21
|
): Type<RecordTypeValue<R>> => {
|
|
19
22
|
type T = RecordTypeValue<R>;
|
|
20
23
|
|
|
24
|
+
const sourceKeys = new Set(Object.keys(source));
|
|
25
|
+
|
|
21
26
|
const typeNameFilled: string =
|
|
22
27
|
options?.typeName ??
|
|
23
28
|
`{ ${Object.entries(source)
|
|
24
29
|
.map(([k, v]) => `${k}: ${v.typeName}`)
|
|
25
30
|
.join(', ')} }`;
|
|
26
31
|
|
|
32
|
+
const allowExcessProperties = options?.allowExcessProperties ?? true;
|
|
33
|
+
|
|
27
34
|
const defaultValue: Type<T>['defaultValue'] =
|
|
28
35
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
29
36
|
Object.fromEntries(
|
|
@@ -41,35 +48,55 @@ export const record = <const R extends Record<string, Type<unknown>>>(
|
|
|
41
48
|
]);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const defaultErrors: readonly ValidationError[] = Object.entries(
|
|
52
|
+
source,
|
|
53
|
+
).flatMap(([k, valueType]) => {
|
|
54
|
+
if (!Object.hasOwn(a, k)) {
|
|
55
|
+
if (source[k]?.optional === true) return [];
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
path: [k],
|
|
60
|
+
actualValue: a,
|
|
61
|
+
typeName: typeNameFilled,
|
|
62
|
+
expectedType: typeNameFilled,
|
|
63
|
+
message: `Missing required key "${k}"`,
|
|
64
|
+
} satisfies ValidationErrorWithMessage,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const v = a[k];
|
|
69
|
+
const res = valueType.validate(v);
|
|
70
|
+
|
|
71
|
+
if (Result.isErr(res)) {
|
|
72
|
+
return prependPathToValidationErrors(res.value, k);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [];
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Check for excess properties if allowExcessProperties is false
|
|
79
|
+
if (!allowExcessProperties) {
|
|
80
|
+
const excessKeys = Object.keys(a).filter((key) => !sourceKeys.has(key));
|
|
81
|
+
|
|
82
|
+
if (excessKeys.length > 0) {
|
|
83
|
+
const excessErrors: readonly ValidationError[] = excessKeys.map(
|
|
84
|
+
(key) =>
|
|
85
|
+
({
|
|
86
|
+
path: [key],
|
|
87
|
+
actualValue: a[key],
|
|
53
88
|
typeName: typeNameFilled,
|
|
54
89
|
expectedType: typeNameFilled,
|
|
55
|
-
message: `
|
|
56
|
-
} satisfies ValidationErrorWithMessage,
|
|
57
|
-
|
|
58
|
-
}
|
|
90
|
+
message: `Excess property "${key}" is not allowed`,
|
|
91
|
+
}) satisfies ValidationErrorWithMessage,
|
|
92
|
+
);
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (Result.isErr(res)) {
|
|
64
|
-
return prependPathToValidationErrors(res.value, k);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return [];
|
|
68
|
-
},
|
|
69
|
-
);
|
|
94
|
+
return Result.err([...defaultErrors, ...excessErrors]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
70
97
|
|
|
71
|
-
if (
|
|
72
|
-
return Result.err(
|
|
98
|
+
if (defaultErrors.length > 0) {
|
|
99
|
+
return Result.err(defaultErrors);
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
@@ -97,15 +124,16 @@ export const record = <const R extends Record<string, Type<unknown>>>(
|
|
|
97
124
|
};
|
|
98
125
|
};
|
|
99
126
|
|
|
100
|
-
type RecordTypeValue<R extends
|
|
127
|
+
type RecordTypeValue<R extends ReadonlyRecord<string, Type<unknown>>> =
|
|
101
128
|
TsFortressInternal.RecordTypeValueImpl<R>;
|
|
102
129
|
|
|
103
130
|
namespace TsFortressInternal {
|
|
104
|
-
export type RecordTypeValueImpl<
|
|
105
|
-
|
|
131
|
+
export type RecordTypeValueImpl<
|
|
132
|
+
R extends ReadonlyRecord<string, Type<unknown>>,
|
|
133
|
+
> = RecordTypeValueImplSub<R, OptionalTypeKeys<R>>;
|
|
106
134
|
|
|
107
135
|
type RecordTypeValueImplSub<
|
|
108
|
-
A extends
|
|
136
|
+
A extends ReadonlyRecord<string, Type<unknown>>,
|
|
109
137
|
OptionalKeys extends keyof A,
|
|
110
138
|
> = Readonly<
|
|
111
139
|
{
|
|
@@ -115,7 +143,40 @@ namespace TsFortressInternal {
|
|
|
115
143
|
}
|
|
116
144
|
>;
|
|
117
145
|
|
|
118
|
-
type OptionalTypeKeys<A extends
|
|
146
|
+
type OptionalTypeKeys<A extends ReadonlyRecord<string, Type<unknown>>> = {
|
|
119
147
|
[K in keyof A]: A[K] extends { optional: true } ? K : never;
|
|
120
148
|
}[keyof A];
|
|
121
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Creates a strict record type that does not allow excess properties.
|
|
153
|
+
* This is an alias for `record(source, { allowExcessProperties: false })`.
|
|
154
|
+
*
|
|
155
|
+
* @param source - The record schema definition
|
|
156
|
+
* @param options - Optional configuration (allowExcessProperties will be overridden to false)
|
|
157
|
+
* @returns A Type that validates records without allowing excess properties
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* import { strictRecord, string, number } from 'ts-fortress';
|
|
162
|
+
*
|
|
163
|
+
* const User = strictRecord({
|
|
164
|
+
* name: string(),
|
|
165
|
+
* age: number()
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* User.is({ name: "John", age: 30 }); // true
|
|
169
|
+
* User.is({ name: "John", age: 30, extra: "not allowed" }); // false
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export const strictRecord = <
|
|
173
|
+
const R extends ReadonlyRecord<string, Type<unknown>>,
|
|
174
|
+
>(
|
|
175
|
+
source: R,
|
|
176
|
+
options?: Partial<
|
|
177
|
+
Readonly<{
|
|
178
|
+
typeName: string;
|
|
179
|
+
}>
|
|
180
|
+
>,
|
|
181
|
+
): Type<RecordTypeValue<R>> =>
|
|
182
|
+
record(source, { ...options, allowExcessProperties: false });
|
|
@@ -58,7 +58,27 @@ describe('record', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
describe('validate', () => {
|
|
61
|
-
test('
|
|
61
|
+
test('truthy case', () => {
|
|
62
|
+
const x: UnknownRecord = {
|
|
63
|
+
year: 2000,
|
|
64
|
+
month: 12,
|
|
65
|
+
date: 25,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = ymd.validate(x);
|
|
69
|
+
expect(Result.isOk(result)).toBe(true);
|
|
70
|
+
|
|
71
|
+
if (Result.isOk(result)) {
|
|
72
|
+
expectType<typeof result.value, Ymd>('=');
|
|
73
|
+
expect(result.value).toStrictEqual({
|
|
74
|
+
year: 2000,
|
|
75
|
+
month: 12,
|
|
76
|
+
date: 25,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('falsy case 1', () => {
|
|
62
82
|
const x: UnknownRecord = {
|
|
63
83
|
year: 2000,
|
|
64
84
|
month: 'ab',
|
|
@@ -91,6 +111,38 @@ describe('record', () => {
|
|
|
91
111
|
]);
|
|
92
112
|
}
|
|
93
113
|
});
|
|
114
|
+
|
|
115
|
+
test('falsy case 2', () => {
|
|
116
|
+
const x: UnknownRecord = {
|
|
117
|
+
year: 2000,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const result = ymd.validate(x);
|
|
121
|
+
expect(Result.isErr(result)).toBe(true);
|
|
122
|
+
|
|
123
|
+
if (Result.isErr(result)) {
|
|
124
|
+
expect(result.value).toStrictEqual([
|
|
125
|
+
{
|
|
126
|
+
path: ['month'],
|
|
127
|
+
actualValue: { year: 2000 },
|
|
128
|
+
typeName: '{ year: number, month: number, date: number }',
|
|
129
|
+
expectedType: '{ year: number, month: number, date: number }',
|
|
130
|
+
message: 'Missing required key "month"',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
path: ['date'],
|
|
134
|
+
actualValue: { year: 2000 },
|
|
135
|
+
typeName: '{ year: number, month: number, date: number }',
|
|
136
|
+
expectedType: '{ year: number, month: number, date: number }',
|
|
137
|
+
message: 'Missing required key "date"',
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
expect(validationErrorsToMessages(result.value)).toStrictEqual([
|
|
141
|
+
'Missing required key "month" at month',
|
|
142
|
+
'Missing required key "date" at date',
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
94
146
|
});
|
|
95
147
|
|
|
96
148
|
describe('fill', () => {
|
|
@@ -208,6 +260,24 @@ describe('partial record', () => {
|
|
|
208
260
|
});
|
|
209
261
|
|
|
210
262
|
describe('validate', () => {
|
|
263
|
+
test('truthy case', () => {
|
|
264
|
+
const x: UnknownRecord = {
|
|
265
|
+
year: 2000,
|
|
266
|
+
month: 12,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const result = ymd.validate(x);
|
|
270
|
+
expect(Result.isOk(result)).toBe(true);
|
|
271
|
+
|
|
272
|
+
if (Result.isOk(result)) {
|
|
273
|
+
expectType<typeof result.value, Ymd>('=');
|
|
274
|
+
expect(result.value).toStrictEqual({
|
|
275
|
+
year: 2000,
|
|
276
|
+
month: 12,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
211
281
|
test('falsy case', () => {
|
|
212
282
|
const x: UnknownRecord = {
|
|
213
283
|
year: 2000,
|