Skip to content

Commit a0892d6

Browse files
committed
Fixed types for constraints, tainted and errors when using intersections and unions in schemas.
1 parent 1f424ed commit a0892d6

File tree

8 files changed

+176
-44
lines changed

8 files changed

+176
-44
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Headlines: Added, Changed, Deprecated, Removed, Fixed, Security
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- Fixed types for constraints, tainted and errors when using intersections and unions in schemas.
13+
814
## [2.8.1] - 2024-03-07
915

1016
### Added

src/lib/client/superForm.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export type SuperForm<
230230
options: T extends T ? FormOptions<T, M> : never; // Need this to distribute T so it works with unions
231231

232232
enhance: (el: HTMLFormElement, events?: SuperFormEvents<T, M>) => ReturnType<typeof enhance>;
233-
isTainted: (path?: FormPath<T> | TaintedFields<T> | boolean) => boolean;
233+
isTainted: (path?: T extends T ? FormPath<T> | TaintedFields<T> | boolean : never) => boolean;
234234
reset: (options?: ResetOptions<T>) => void;
235235
submit: (submitter?: HTMLElement | null) => void;
236236

@@ -1400,10 +1400,7 @@ export function superForm<
14001400
rebind({ form: snapshot, untaint: snapshot.tainted ?? true });
14011401
}) as T extends T ? Restore<T, M> : never,
14021402

1403-
async validate<Path extends FormPathLeaves<T>>(
1404-
path: Path,
1405-
opts: ValidateOptions<FormPathType<T, Path>, Partial<T>, Record<string, unknown>> = {}
1406-
) {
1403+
async validate(path, opts = {}) {
14071404
if (!options.validators) {
14081405
throw new SuperFormError('options.validators must be set to use the validate method.');
14091406
}
@@ -1466,7 +1463,11 @@ export function superForm<
14661463
}
14671464

14681465
const result = opts.update
1469-
? await Form_clientValidation({ paths: [] }, true, opts.schema)
1466+
? await Form_clientValidation(
1467+
{ paths: [] },
1468+
true,
1469+
opts.schema as ValidationAdapter<Partial<T>> | undefined
1470+
)
14701471
: Form_validate({ adapter: opts.schema });
14711472

14721473
if (opts.update && EnhancedForm) {

src/lib/jsonSchema/constraints.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SuperStruct } from '$lib/superStruct.js';
22
import type { JSONSchema } from './index.js';
33
import { schemaInfo, type SchemaInfo } from './schemaInfo.js';
4+
import { merge as deepMerge } from 'ts-deepmerge';
45

56
export type InputConstraint = Partial<{
67
pattern: string; // RegExp
@@ -21,14 +22,12 @@ export function constraints<T extends Record<string, unknown>>(
2122
}
2223

2324
function merge<T extends Record<string, unknown>>(
24-
constraints: (InputConstraints<T> | InputConstraint | undefined)[]
25+
...constraints: (InputConstraints<T> | InputConstraint | undefined)[]
2526
): ReturnType<typeof _constraints> {
26-
let output = {};
27-
for (const constraint of constraints) {
28-
if (!constraint) continue;
29-
output = { ...output, ...constraint };
30-
}
31-
return output;
27+
const filtered = constraints.filter((c) => !!c);
28+
if (!filtered.length) return undefined;
29+
if (filtered.length == 1) return filtered[0];
30+
return deepMerge(...(filtered as Record<string, unknown>[]));
3231
}
3332

3433
function _constraints<T extends Record<string, unknown>>(
@@ -37,34 +36,34 @@ function _constraints<T extends Record<string, unknown>>(
3736
): InputConstraints<T> | InputConstraint | undefined {
3837
if (!info) return undefined;
3938

39+
let output: Record<string, unknown> | undefined = undefined;
40+
4041
// Union
41-
if (info.union) {
42+
if (info.union && info.union.length) {
4243
const infos = info.union.map((s) => schemaInfo(s, info.isOptional, path));
4344
const merged = infos.map((i) => _constraints(i, path));
44-
const output = merge(merged);
45+
output = merge(output, ...merged);
46+
4547
// Delete required if any part of the union is optional
4648
if (
4749
output &&
4850
(info.isNullable || info.isOptional || infos.some((i) => i?.isNullable || i?.isOptional))
4951
) {
5052
delete output.required;
5153
}
52-
return output && Object.values(output).length ? output : undefined;
5354
}
5455

5556
// Arrays
5657
if (info.array) {
57-
if (info.array.length == 1) {
58-
//console.log('Array constraint', schema, path);
59-
return _constraints(schemaInfo(info.array[0], info.isOptional, path), path);
60-
}
61-
62-
return merge(info.array.map((i) => _constraints(schemaInfo(i, info.isOptional, path), path)));
58+
output = merge(
59+
output,
60+
...info.array.map((i) => _constraints(schemaInfo(i, info.isOptional, path), path))
61+
);
6362
}
6463

6564
// Objects
6665
if (info.properties) {
67-
const output: Record<string, unknown> = {};
66+
const obj = {} as Record<string, unknown>;
6867
for (const [key, prop] of Object.entries(info.properties)) {
6968
const propInfo = schemaInfo(
7069
prop,
@@ -74,13 +73,13 @@ function _constraints<T extends Record<string, unknown>>(
7473
const propConstraint = _constraints(propInfo, [...path, key]);
7574

7675
if (typeof propConstraint === 'object' && Object.values(propConstraint).length > 0) {
77-
output[key] = propConstraint;
76+
obj[key] = propConstraint;
7877
}
7978
}
80-
return output;
79+
output = merge(output, obj);
8180
}
8281

83-
return constraint(info);
82+
return output ?? constraint(info);
8483
}
8584

8685
function constraint(info: SchemaInfo): InputConstraint | undefined {

src/lib/stringPath.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IsAny } from './utils.js';
1+
import type { AllKeys, IsAny, MergeUnion } from './utils.js';
22

33
/* eslint-disable @typescript-eslint/no-explicit-any */
44
export function splitPath(path: string) {
@@ -21,15 +21,6 @@ export function mergePath(path: (string | number | symbol)[]) {
2121

2222
type BuiltInObjects = Date | Set<unknown> | File;
2323

24-
export type AllKeys<T> = T extends T ? keyof T : never;
25-
26-
export type PickType<T, K extends AllKeys<T>> = T extends { [k in K]: any } ? T[K] : never;
27-
28-
// Thanks to https://dev.to/lucianbc/union-type-merging-in-typescript-9al
29-
export type MergeUnion<T> = {
30-
[K in AllKeys<T>]: PickType<T, K>;
31-
};
32-
3324
/**
3425
* Lists all paths in an object as string accessors.
3526
*/

src/lib/superStruct.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import type { AllKeys, MergeUnion } from './utils.js';
2+
13
export type SuperStructArray<T extends Record<string, unknown>, Data, ArrayData = unknown> = {
24
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3-
[Property in keyof T]?: T extends any
5+
[Property in AllKeys<T>]?: [T] extends [any]
46
? NonNullable<T[Property]> extends Record<string, unknown>
5-
? SuperStructArray<NonNullable<T[Property]>, Data, ArrayData>
7+
? SuperStructArray<MergeUnion<NonNullable<T[Property]>>, Data, ArrayData>
68
: NonNullable<T[Property]> extends (infer A)[]
79
? ArrayData &
810
Record<
911
number | string,
1012
NonNullable<A> extends Record<string, unknown>
11-
? SuperStructArray<NonNullable<A>, Data, ArrayData>
13+
? SuperStructArray<MergeUnion<NonNullable<A>>, Data, ArrayData>
1214
: Data
1315
>
1416
: Data
@@ -17,12 +19,12 @@ export type SuperStructArray<T extends Record<string, unknown>, Data, ArrayData
1719

1820
export type SuperStruct<T extends Record<string, unknown>, Data> = Partial<{
1921
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20-
[Property in keyof T]: T extends any
22+
[Property in AllKeys<T>]: [T] extends [any]
2123
? NonNullable<T[Property]> extends Record<string, unknown>
22-
? SuperStruct<NonNullable<T[Property]>, Data>
24+
? SuperStruct<MergeUnion<NonNullable<T[Property]>>, Data>
2325
: NonNullable<T[Property]> extends (infer A)[]
2426
? NonNullable<A> extends Record<string, unknown>
25-
? SuperStruct<NonNullable<A>, Data>
27+
? SuperStruct<MergeUnion<NonNullable<A>>, Data>
2628
: Data
2729
: Data
2830
: never;

src/lib/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,13 @@ export function assertSchema(
3535
throw new SchemaError('Schema property cannot be defined as boolean.', path);
3636
}
3737
}
38+
39+
export type AllKeys<T> = T extends T ? keyof T : never;
40+
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
type PickType<T, K extends AllKeys<T>> = T extends { [k in K]: any } ? T[K] : never;
43+
44+
// Thanks to https://dev.to/lucianbc/union-type-merging-in-typescript-9al
45+
export type MergeUnion<T> = {
46+
[K in AllKeys<T>]: PickType<T, K>;
47+
};

src/routes/(v2)/v2/zod-discriminated/+page.svelte

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
import { superForm } from '$lib/index.js';
44
import SuperDebug from '$lib/index.js';
55
import { ProfileType } from './schema.js';
6+
// import type { UserProfileSchema } from './schema';
67
export let data;
78
8-
const { form, errors, message, enhance } = superForm(data.form, {
9+
const { form, errors, message, enhance, tainted, isTainted, constraints } = superForm(data.form, {
910
dataType: 'json'
1011
});
12+
13+
let yearTainted: boolean = isTainted($tainted?.typeData?.yearOfStudy);
14+
yearTainted;
1115
</script>
1216

1317
<SuperDebug data={$form} />
@@ -53,36 +57,81 @@
5357
{#if $form.type === ProfileType.STUDENT}
5458
<label>
5559
Year of Study<br />
56-
<input name="yearOfStudy" type="number" bind:value={$form.typeData.yearOfStudy} />
60+
<input
61+
name="yearOfStudy"
62+
type="number"
63+
bind:value={$form.typeData.yearOfStudy}
64+
{...$constraints.typeData?.yearOfStudy}
65+
/>
66+
{#if $errors.typeData?.yearOfStudy}
67+
<p>
68+
{$errors.typeData?.yearOfStudy}
69+
</p>
70+
{/if}
5771
</label>
5872
<label>
5973
Branch<br />
6074
<input name="branch" type="text" bind:value={$form.typeData.branch} />
75+
{#if $errors.typeData?.branch}
76+
<p>
77+
{$errors.typeData.branch}
78+
</p>
79+
{/if}
6180
</label>
6281
<label>
6382
Department<br />
6483
<input name="department" type="text" bind:value={$form.typeData.department} />
84+
{#if $errors.typeData?.department}
85+
<p>
86+
{$errors.typeData?.department}
87+
</p>
88+
{/if}
6589
</label>
6690
<label>
6791
Student ID<br />
6892
<input name="studentId" type="text" bind:value={$form.typeData.studentId} />
93+
{#if $errors.typeData?.studentId}
94+
<p>
95+
{$errors.typeData?.studentId}
96+
</p>
97+
{/if}
6998
</label>
7099
{:else if $form.type === ProfileType.FACULTY}
71100
<label>
72101
Designation<br />
73102
<input name="designation" type="text" bind:value={$form.typeData.designation} />
103+
{#if $errors.typeData?.designation && $errors.typeData.designation.length}
104+
<p>
105+
{$errors.typeData?.designation}
106+
</p>
107+
{/if}
74108
</label>
75109
<label>
76110
Branch<br />
77111
<input name="branch" type="text" bind:value={$form.typeData.branch} />
112+
{#if $errors.typeData?.branch}
113+
<p>
114+
{$errors.typeData?.branch}
115+
</p>
116+
{/if}
78117
</label>
79118
<label>
80119
Department<br />
81120
<input name="department" type="text" bind:value={$form.typeData.department} />
121+
{#if $errors.typeData?.department}
122+
<p>
123+
{$errors.typeData?.department}
124+
</p>
125+
{/if}
82126
</label>
83127
<label>
84128
Faculty Id<br />
85129
<input name="facultyId" type="text" bind:value={$form.typeData.facultyId} />
130+
{#if $errors.typeData?.facultyId}
131+
<p>
132+
{$errors.typeData?.facultyId}
133+
</p>
134+
{/if}
86135
</label>
87136
{/if}
88137

src/tests/JSONSchema.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,80 @@ describe('Constraints', () => {
430430
}
431431
});
432432
});
433+
434+
it('should merge the constraints for intersections', () => {
435+
enum ProfileType {
436+
STUDENT = 'STUDENT',
437+
FACULTY = 'FACULTY',
438+
STAFF = 'STAFF'
439+
}
440+
441+
const studentZSchema = z.object({
442+
yearOfStudy: z.number().min(1),
443+
branch: z.string().min(2),
444+
department: z.string().min(2),
445+
studentId: z.string().min(2),
446+
clubs: z.array(z.string()).optional()
447+
});
448+
449+
const facultyZSchema = z.object({
450+
department: z.string().min(2),
451+
branch: z.string().min(2),
452+
designation: z.string().min(2),
453+
facultyId: z.string().min(2)
454+
});
455+
456+
const staffZSchema = z.object({
457+
department: z.string().min(2),
458+
branch: z.string().min(2),
459+
designation: z.string().min(2),
460+
staffId: z.string().min(2)
461+
});
462+
463+
const profileSchema = z
464+
.discriminatedUnion('type', [
465+
z.object({
466+
type: z.literal(ProfileType.STUDENT),
467+
typeData: studentZSchema
468+
}),
469+
z.object({
470+
type: z.literal(ProfileType.FACULTY),
471+
typeData: facultyZSchema
472+
}),
473+
z.object({
474+
type: z.literal(ProfileType.STAFF),
475+
typeData: staffZSchema
476+
})
477+
])
478+
.default({
479+
type: ProfileType.STUDENT,
480+
typeData: { yearOfStudy: 1, branch: '', department: '', studentId: '' }
481+
});
482+
483+
const UserProfileZodSchema = z
484+
.object({
485+
name: z.string().min(2),
486+
email: z.string().email(),
487+
type: z.nativeEnum(ProfileType)
488+
})
489+
.and(profileSchema);
490+
491+
const jsonSchema = zod(UserProfileZodSchema).jsonSchema;
492+
expect(constraints(jsonSchema)).toEqual({
493+
type: { required: true },
494+
typeData: {
495+
yearOfStudy: { min: 1, required: true },
496+
branch: { minlength: 2, required: true },
497+
department: { minlength: 2, required: true },
498+
studentId: { minlength: 2, required: true },
499+
designation: { minlength: 2, required: true },
500+
facultyId: { minlength: 2, required: true },
501+
staffId: { minlength: 2, required: true }
502+
},
503+
name: { minlength: 2, required: true },
504+
email: { required: true }
505+
});
506+
});
433507
});
434508

435509
describe('Unions', () => {

0 commit comments

Comments
 (0)