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.
Files changed (33) hide show
  1. package/README.md +80 -1
  2. package/dist/index.mjs +1 -1
  3. package/dist/record/index.mjs +1 -1
  4. package/dist/record/key-value-record.d.mts +1 -1
  5. package/dist/record/key-value-record.d.mts.map +1 -1
  6. package/dist/record/record.d.mts +31 -5
  7. package/dist/record/record.d.mts.map +1 -1
  8. package/dist/record/record.mjs +42 -4
  9. package/dist/record/record.mjs.map +1 -1
  10. package/package.json +1 -1
  11. package/src/array/array-of-length.test.mts +16 -1
  12. package/src/array/array.test.mts +16 -1
  13. package/src/array/non-empty-array.test.mts +16 -1
  14. package/src/array/tuple.test.mts +18 -1
  15. package/src/compose/intersection.test.mts +43 -1
  16. package/src/compose/merge-records.test.mts +16 -1
  17. package/src/compose/union.test.mts +16 -1
  18. package/src/enum/enum.test.mts +16 -1
  19. package/src/enum/uint-range.test.mts +14 -1
  20. package/src/primitives/null.test.mts +170 -0
  21. package/src/primitives/number.test.mts +284 -0
  22. package/src/primitives/string.test.mts +341 -0
  23. package/src/primitives/undefined.test.mts +179 -0
  24. package/src/record/key-value-record.mts +4 -4
  25. package/src/record/key-value-record.test.mts +28 -2
  26. package/src/record/keyof.test.mts +12 -0
  27. package/src/record/partial.test.mts +18 -0
  28. package/src/record/record-allow-excess-properties.test.mts +180 -0
  29. package/src/record/record-strict-composition-simple.test.mts +134 -0
  30. package/src/record/record-strict-composition.test.mts +403 -0
  31. package/src/record/record.mts +92 -31
  32. package/src/record/record.test.mts +71 -1
  33. 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
+ });
@@ -8,22 +8,29 @@ import {
8
8
  type ValidationErrorWithMessage,
9
9
  } from '../validation-error.mjs';
10
10
 
11
- export const record = <const R extends Record<string, Type<unknown>>>(
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 errors: readonly ValidationError[] = Object.entries(source).flatMap(
45
- ([k, valueType]) => {
46
- if (!Object.hasOwn(a, k)) {
47
- if (source[k]?.optional === true) return [];
48
-
49
- return [
50
- {
51
- path: [k],
52
- actualValue: a,
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: `Missing required key "${k}"`,
56
- } satisfies ValidationErrorWithMessage,
57
- ];
58
- }
90
+ message: `Excess property "${key}" is not allowed`,
91
+ }) satisfies ValidationErrorWithMessage,
92
+ );
59
93
 
60
- const v = a[k];
61
- const res = valueType.validate(v);
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 (errors.length > 0) {
72
- return Result.err(errors);
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 Record<string, Type<unknown>>> =
127
+ type RecordTypeValue<R extends ReadonlyRecord<string, Type<unknown>>> =
101
128
  TsFortressInternal.RecordTypeValueImpl<R>;
102
129
 
103
130
  namespace TsFortressInternal {
104
- export type RecordTypeValueImpl<R extends Record<string, Type<unknown>>> =
105
- RecordTypeValueImplSub<R, OptionalTypeKeys<R>>;
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 Record<string, Type<unknown>>,
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 Record<string, Type<unknown>>> = {
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('falsy case', () => {
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,