Skip to content

Commit

Permalink
feat: better Lazy types and deepPartial fixes (#1748)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The types for Lazy have changes a bit, it's unlikely that this affects anyone but it is technically a breaking change.
  • Loading branch information
jquense authored Aug 19, 2022
1 parent 9fce714 commit e4ae6ed
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 43 deletions.
53 changes: 29 additions & 24 deletions src/Lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,27 @@ import type {
SchemaLazyDescription,
} from './schema';
import { Flags } from './util/types';
import { Schema } from '.';
import { InferType, Schema } from '.';

export type LazyBuilder<
T,
TSchema extends ISchema<TContext>,
TContext = AnyObject,
TDefault = any,
TFlags extends Flags = any,
> = (
value: any,
options: ResolveOptions,
) => ISchema<T, TContext, TFlags, TDefault>;
> = (value: any, options: ResolveOptions) => TSchema;

export function create<
T,
TSchema extends ISchema<any, TContext>,
TContext = AnyObject,
TFlags extends Flags = any,
TDefault = any,
>(builder: LazyBuilder<T, TContext, TDefault, TFlags>) {
return new Lazy<T, TContext, TDefault, TFlags>(builder);
>(builder: (value: any, options: ResolveOptions<TContext>) => TSchema) {
return new Lazy<InferType<TSchema>, TContext>(builder);
}

export interface LazySpec {
meta: Record<string, unknown> | undefined;
optional: boolean;
}

class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
implements ISchema<T, TContext, TFlags, TDefault>
class Lazy<T, TContext = AnyObject, TFlags extends Flags = any>
implements ISchema<T, TContext, TFlags, undefined>
{
type = 'lazy' as const;

Expand All @@ -48,37 +42,48 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
declare readonly __outputType: T;
declare readonly __context: TContext;
declare readonly __flags: TFlags;
declare readonly __default: TDefault;
declare readonly __default: undefined;

spec: LazySpec;

constructor(private builder: LazyBuilder<T, TContext, TDefault, TFlags>) {
this.spec = { meta: undefined };
constructor(private builder: any) {
this.spec = { meta: undefined, optional: false };
}

clone(): Lazy<T, TContext, TDefault, TFlags> {
const next = create(this.builder);
next.spec = { ...this.spec };
clone(spec?: Partial<LazySpec>): Lazy<T, TContext, TFlags> {
const next = new Lazy<T, TContext, TFlags>(this.builder);
next.spec = { ...this.spec, ...spec };
return next;
}

private _resolve = (
value: any,
options: ResolveOptions<TContext> = {},
): Schema<T, TContext, TDefault, TFlags> => {
): Schema<T, TContext, undefined, TFlags> => {
let schema = this.builder(value, options) as Schema<
T,
TContext,
TDefault,
undefined,
TFlags
>;

if (!isSchema(schema))
throw new TypeError('lazy() functions must return a valid schema');

if (this.spec.optional) schema = schema.optional();

return schema.resolve(options);
};

private optionality(optional: boolean) {
const next = this.clone({ optional });
return next;
}

optional(): Lazy<T | undefined, TContext, TFlags> {
return this.optionality(true);
}

resolve(options: ResolveOptions<TContext>) {
return this._resolve(options.value, options);
}
Expand Down Expand Up @@ -129,7 +134,7 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
}

meta(): Record<string, unknown> | undefined;
meta(obj: Record<string, unknown>): Lazy<T, TContext, TDefault, TFlags>;
meta(obj: Record<string, unknown>): Lazy<T, TContext, TFlags>;
meta(...args: [Record<string, unknown>?]) {
if (args.length === 0) return this.spec.meta;

Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@ import { create as lazyCreate } from './Lazy';
import ValidationError from './ValidationError';
import reach, { getIn } from './util/reach';
import isSchema from './util/isSchema';
import setLocale from './setLocale';
import Schema, { AnySchema } from './schema';
import setLocale, { LocaleObject } from './setLocale';
import Schema, {
AnySchema,
SchemaRefDescription,
SchemaInnerTypeDescription,
SchemaObjectDescription,
SchemaLazyDescription,
SchemaFieldDescription,
SchemaDescription,
} from './schema';
import type { InferType } from './types';

function addMethod<T extends AnySchema>(
Expand Down Expand Up @@ -50,6 +58,13 @@ export type {
AnySchema,
MixedOptions,
TypeGuard,
SchemaRefDescription,
SchemaInnerTypeDescription,
SchemaObjectDescription,
SchemaLazyDescription,
SchemaFieldDescription,
SchemaDescription,
LocaleObject,
};

export {
Expand Down
41 changes: 32 additions & 9 deletions src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import type {
import parseJson from './util/parseJson';
import type { Test } from './util/createValidation';
import type ValidationError from './ValidationError';

export type { AnyObject };

type MakeKeysOptional<T> = T extends AnyObject ? _<MakePartial<T>> : T;
Expand All @@ -37,6 +36,31 @@ export type ObjectSchemaSpec = SchemaSpec<any> & {
noUnknown?: boolean;
};

function deepPartial(schema: any) {
if ('fields' in schema) {
const partial: any = {};
for (const [key, fieldSchema] of Object.entries(schema.fields)) {
partial[key] = deepPartial(fieldSchema);
}
return schema.setFields(partial);
}
if (schema.type === 'array') {
const nextArray = schema.optional();
if (nextArray.innerType)
nextArray.innerType = deepPartial(nextArray.innerType);
return nextArray;
}
if (schema.type === 'tuple') {
return schema
.optional()
.clone({ types: schema.spec.types.map(deepPartial) });
}
if ('optional' in schema) {
return schema.optional();
}
return schema;
}

const deepHas = (obj: any, p: string) => {
const path = [...normalizePath(p)];
if (path.length === 1) return path[0] in obj;
Expand Down Expand Up @@ -342,19 +366,18 @@ export default class ObjectSchema<
partial() {
const partial: any = {};
for (const [key, schema] of Object.entries(this.fields)) {
partial[key] = schema instanceof Schema ? schema.optional() : schema;
partial[key] =
'optional' in schema && schema.optional instanceof Function
? schema.optional()
: schema;
}

return this.setFields<Partial<TIn>, TDefault>(partial);
}

deepPartial() {
const partial: any = {};
for (const [key, schema] of Object.entries(this.fields)) {
if (schema instanceof ObjectSchema) partial[key] = schema.deepPartial();
else partial[key] = schema instanceof Schema ? schema.optional() : schema;
}
return this.setFields<PartialDeep<TIn>, TDefault>(partial);
deepPartial(): ObjectSchema<PartialDeep<TIn>, TContext, TDefault, TFlags> {
const next = deepPartial(this);
return next;
}

pick<TKey extends keyof TIn>(keys: readonly TKey[]) {
Expand Down
2 changes: 2 additions & 0 deletions src/setLocale.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import locale, { LocaleObject } from './locale';

export type { LocaleObject };

export default function setLocale(custom: LocaleObject) {
Object.keys(custom).forEach((type) => {
// @ts-ignore
Expand Down
20 changes: 14 additions & 6 deletions test/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,14 +686,15 @@ describe('Object types', () => {
await expect(inst.partial().isValid({ name: '' })).resolves.toEqual(false);
});

xit('deepPartial() should work', async () => {
it('deepPartial() should work', async () => {
let inst = object({
age: number().required(),
name: string().required(),
contacts: array(
object({
name: string().required(),
age: number().required(),
lazy: lazy(() => number().required()),
}),
).defined(),
});
Expand All @@ -703,17 +704,24 @@ describe('Object types', () => {
inst.isValid({ age: 2, name: 'fs', contacts: [{}] }),
).resolves.toEqual(false);

await expect(inst.deepPartial().isValid({})).resolves.toEqual(true);
const instPartial = inst.deepPartial();

await expect(
inst.deepPartial().validate({ contacts: [{}] }),
).resolves.toEqual(true);
inst.validate({ age: 1, name: 'f', contacts: [{ name: 'f', age: 1 }] }),
).rejects.toThrowError('contacts[0].lazy is a required field');

await expect(instPartial.isValid({})).resolves.toEqual(true);

await expect(instPartial.isValid({ contacts: [{}] })).resolves.toEqual(
true,
);

await expect(
inst.deepPartial().isValid({ contacts: [{ age: null }] }),
instPartial.isValid({ contacts: [{ age: null }] }),
).resolves.toEqual(false);

await expect(
inst.deepPartial().isValid({ contacts: [{ age: null }] }),
instPartial.isValid({ contacts: [{ lazy: null }] }),
).resolves.toEqual(false);
});

Expand Down
19 changes: 17 additions & 2 deletions test/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,15 @@ bool: {
Lazy: {
const l = lazy(() => string().default('asfasf'));

// $ExpectType string
l.cast(null);

const l2 = lazy((v) =>
v ? string().default('asfasf') : number().required(),
);

// $ExpectType string | number
l2.cast(null);
}

Array: {
Expand Down Expand Up @@ -885,6 +893,7 @@ Object: {
const schema = object({
// age: number(),
name: string().required(),
lazy: lazy(() => number().defined()),
address: object()
.shape({
line1: string().required(),
Expand All @@ -896,18 +905,24 @@ Object: {
const partial = schema.partial();

// $ExpectType string | undefined
partial.validateSync({ age: '1' })!.name;
partial.validateSync({})!.name;

// $ExpectType string
partial.validateSync({})!.address!.line1;

// $ExpectType number | undefined
partial.validateSync({})!.lazy;

const deepPartial = schema.deepPartial();

// $ExpectType string | undefined
deepPartial.validateSync({ age: '1' })!.name;
deepPartial.validateSync({})!.name;

// $ExpectType string | undefined
deepPartial.validateSync({})!.address!.line1;

// $ExpectType number | undefined
deepPartial.validateSync({})!.lazy;
}
}

Expand Down

0 comments on commit e4ae6ed

Please sign in to comment.