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: add cast nullability migration path. #1749

Merged
merged 1 commit into from
Aug 19, 2022
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/Lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import type { ResolveOptions } from './Condition';

import type {
CastOptionalityOptions,
CastOptions,
SchemaFieldDescription,
SchemaLazyDescription,
Expand Down Expand Up @@ -88,8 +89,16 @@ class Lazy<T, TContext = AnyObject, TFlags extends Flags = any>
return this._resolve(options.value, options);
}

cast(value: any, options?: CastOptions<TContext>): T {
return this._resolve(value, options).cast(value, options);
cast(value: any, options?: CastOptions<TContext>): T;
cast(
value: any,
options?: CastOptionalityOptions<TContext>,
): T | null | undefined;
cast(
value: any,
options?: CastOptions<TContext> | CastOptionalityOptions<TContext>,
): any {
return this._resolve(value, options).cast(value, options as any);
}

asNestedTest(options: NestedTestConfig) {
Expand Down
38 changes: 29 additions & 9 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ export interface CastOptions<C = {}> {
path?: string;
}

export interface CastOptionalityOptions<C = {}>
extends Omit<CastOptions<C>, 'assert'> {
/**
* Whether or not to throw TypeErrors if casting fails to produce a valid type.
* defaults to `true`. The `'ignore-optionality'` options is provided as a migration
* path from pre-v1 where `schema.nullable().required()` was allowed. When provided
* cast will only throw for values that are the wrong type *not* including `null` and `undefined`
*/
assert: 'ignore-optionality';
}

export type RunTest = (
opts: TestOptions,
panic: PanicCallback,
Expand Down Expand Up @@ -328,19 +339,33 @@ export default abstract class Schema<
/**
* Run the configured transform pipeline over an input value.
*/
cast(value: any, options: CastOptions<TContext> = {}): this['__outputType'] {
cast(value: any, options?: CastOptions<TContext>): this['__outputType'];
cast(
value: any,
options: CastOptionalityOptions<TContext>,
): this['__outputType'] | null | undefined;
cast(
value: any,
options: CastOptions<TContext> | CastOptionalityOptions<TContext> = {},
): this['__outputType'] {
let resolvedSchema = this.resolve({
value,
...options,
// parent: options.parent,
// context: options.context,
});
let allowOptionality = options.assert === 'ignore-optionality';

let result = resolvedSchema._cast(value, options);
let result = resolvedSchema._cast(value, options as any);

if (options.assert !== false && !resolvedSchema.isType(result)) {
if (allowOptionality && isAbsent(result)) {
return result as any;
}

let formattedValue = printValue(value);
let formattedResult = printValue(result);

throw new TypeError(
`The value of ${
options.path || 'field'
Expand Down Expand Up @@ -523,8 +548,7 @@ export default abstract class Schema<
validate(
value: any,
options?: ValidateOptions<TContext>,
): Promise<this['__outputType']>;
validate(value: any, options?: ValidateOptions<TContext>): any {
): Promise<this['__outputType']> {
let schema = this.resolve({ ...options, value });

return new Promise((resolve, reject) =>
Expand All @@ -537,16 +561,12 @@ export default abstract class Schema<
},
(errors, validated) => {
if (errors.length) reject(new ValidationError(errors!, validated));
else resolve(validated);
else resolve(validated as this['__outputType']);
},
),
);
}

validateSync(
value: any,
options?: ValidateOptions<TContext>,
): this['__outputType'];
validateSync(
value: any,
options?: ValidateOptions<TContext>,
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ResolveOptions } from './Condition';
import type {
AnySchema,
CastOptionalityOptions,
CastOptions,
SchemaFieldDescription,
SchemaSpec,
Expand All @@ -18,6 +19,8 @@ export interface ISchema<T, C = AnyObject, F extends Flags = any, D = any> {
__default: D;

cast(value: any, options?: CastOptions<C>): T;
cast(value: any, options: CastOptionalityOptions<C>): T | null | undefined;

validate(value: any, options?: ValidateOptions<C>): Promise<T>;

asNestedTest(config: NestedTestConfig): Test;
Expand Down
10 changes: 10 additions & 0 deletions test/mixed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ describe('Mixed Types ', () => {
);
});

it('should allow missing values with the "ignore-optionality" option', () => {
expect(
string().required().cast(null, { assert: 'ignore-optionality' }),
).toBe(null);

expect(
string().required().cast(undefined, { assert: 'ignore-optionality' }),
).toBe(undefined);
});

it('should warn about null types', async () => {
await expect(string().strict().validate(null)).rejects.toThrowError(
/this cannot be null/,
Expand Down
6 changes: 6 additions & 0 deletions test/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ Mixed: {
type: 'string',
check: (value): value is string => typeof value === 'string',
});

// $ExpectType string
mixed<string>().defined().cast('', { assert: true });

// $ExpectType string | null | undefined
mixed<string>().defined().cast('', { assert: 'ignore-optionality' });
}

Strings: {
Expand Down