Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(schema-utils): Support LooseRecord key validation #22404

Merged
merged 2 commits into from May 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/util/schema-utils.spec.ts
Expand Up @@ -49,6 +49,42 @@ describe('util/schema-utils', () => {
expect(s.parse({ foo: 'foo', bar: 123 })).toEqual({ foo: 'foo' });
});

it('supports key schema', () => {
const s = LooseRecord(
z.string().refine((x) => x === 'bar'),
z.string()
);
expect(s.parse({ foo: 'foo', bar: 'bar' })).toEqual({ bar: 'bar' });
});

it('reports key schema errors', () => {
let errorData: unknown = null;
const s = LooseRecord(
z.string().refine((x) => x === 'bar'),
z.string(),
{
onError: (x) => {
errorData = x;
},
}
);

s.parse({ foo: 'foo', bar: 'bar' });

expect(errorData).toMatchObject({
error: {
issues: [
{
code: 'custom',
message: 'Invalid input',
path: ['foo'],
},
],
},
input: { bar: 'bar', foo: 'foo' },
});
});

it('runs callback for wrong elements', () => {
let err: z.ZodError | undefined = undefined;
const Schema = LooseRecord(
Expand Down
109 changes: 88 additions & 21 deletions lib/util/schema-utils.ts
Expand Up @@ -67,54 +67,121 @@ export function LooseArray<Schema extends z.ZodTypeAny>(
});
}

type LooseRecordResult<
KeySchema extends z.ZodTypeAny,
ValueSchema extends z.ZodTypeAny
> = z.ZodEffects<
z.ZodRecord<z.ZodString, z.ZodAny>,
Record<z.TypeOf<KeySchema>, z.TypeOf<ValueSchema>>,
Record<z.TypeOf<KeySchema>, any>
>;

type LooseRecordOpts<
KeySchema extends z.ZodTypeAny,
ValueSchema extends z.ZodTypeAny
> = LooseOpts<Record<z.TypeOf<KeySchema> | z.TypeOf<ValueSchema>, unknown>>;

/**
* Works like `z.record()`, but drops wrong elements instead of invalidating the whole record.
*
* **Important**: non-record inputs other are still invalid.
* Use `LooseRecord(...).catch({})` to handle it.
*
* @param Elem Schema for record values
* @param KeyValue Schema for record keys
* @param ValueValue Schema for record values
* @param onError Callback for errors
* @returns Schema for record
*/
export function LooseRecord<Schema extends z.ZodTypeAny>(
Elem: Schema,
{ onError }: LooseOpts<Record<string, unknown>> = {}
): z.ZodEffects<
z.ZodRecord<z.ZodString, z.ZodAny>,
Record<string, z.TypeOf<Schema>>,
Record<string, any>
> {
export function LooseRecord<ValueSchema extends z.ZodTypeAny>(
Value: ValueSchema
): LooseRecordResult<z.ZodString, ValueSchema>;
export function LooseRecord<
KeySchema extends z.ZodTypeAny,
ValueSchema extends z.ZodTypeAny
>(
Key: KeySchema,
Value: ValueSchema
): LooseRecordResult<KeySchema, ValueSchema>;
export function LooseRecord<ValueSchema extends z.ZodTypeAny>(
Value: ValueSchema,
{ onError }: LooseRecordOpts<z.ZodString, ValueSchema>
): LooseRecordResult<z.ZodString, ValueSchema>;
export function LooseRecord<
KeySchema extends z.ZodTypeAny,
ValueSchema extends z.ZodTypeAny
>(
Key: KeySchema,
Value: ValueSchema,
{ onError }: LooseRecordOpts<KeySchema, ValueSchema>
): LooseRecordResult<KeySchema, ValueSchema>;
export function LooseRecord<
KeySchema extends z.ZodTypeAny,
ValueSchema extends z.ZodTypeAny
>(
arg1: ValueSchema | KeySchema,
arg2?: ValueSchema | LooseOpts<Record<string, unknown>>,
arg3?: LooseRecordOpts<KeySchema, ValueSchema>
): LooseRecordResult<KeySchema, ValueSchema> {
let Key: z.ZodSchema = z.any();
let Value: ValueSchema;
let opts: LooseRecordOpts<KeySchema, ValueSchema> = {};
if (arg2 && arg3) {
Key = arg1 as KeySchema;
Value = arg2 as ValueSchema;
opts = arg3;
} else if (arg2) {
if (arg2 instanceof z.ZodType) {
Key = arg1 as KeySchema;
Value = arg2;
} else {
Value = arg1 as ValueSchema;
opts = arg2;
}
} else {
Value = arg1 as ValueSchema;
}

const { onError } = opts;
if (!onError) {
// Avoid error-related computations inside the loop
return z.record(z.any()).transform((input) => {
const output: Record<string, z.infer<Schema>> = {};
const output: Record<string, z.infer<ValueSchema>> = {};
for (const [key, val] of Object.entries(input)) {
const parsed = Elem.safeParse(val);
if (parsed.success) {
output[key] = parsed.data;
const parsedKey = Key.safeParse(key);
const parsedValue = Value.safeParse(val);
if (parsedKey.success && parsedValue.success) {
output[key] = parsedValue.data;
}
}
return output;
});
}

return z.record(z.any()).transform((input) => {
const output: Record<string, z.infer<Schema>> = {};
const output: Record<string, z.infer<ValueSchema>> = {};
const issues: z.ZodIssue[] = [];

for (const [key, val] of Object.entries(input)) {
const parsed = Elem.safeParse(val);

if (parsed.success) {
output[key] = parsed.data;
const parsedKey = Key.safeParse(key);
if (!parsedKey.success) {
for (const issue of parsedKey.error.issues) {
issue.path.unshift(key);
issues.push(issue);
}
continue;
}

for (const issue of parsed.error.issues) {
issue.path.unshift(key);
issues.push(issue);
const parsedValue = Value.safeParse(val);
if (!parsedValue.success) {
for (const issue of parsedValue.error.issues) {
issue.path.unshift(key);
issues.push(issue);
}
continue;
}

output[key] = parsedValue.data;
continue;
}

if (issues.length) {
Expand Down