Skip to content

Commit 2bb099e

Browse files
authored
feat: add cast nullability migration path. (#1749)
1 parent e4ae6ed commit 2bb099e

File tree

5 files changed

+59
-11
lines changed

5 files changed

+59
-11
lines changed

src/Lazy.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import type { ResolveOptions } from './Condition';
99

1010
import type {
11+
CastOptionalityOptions,
1112
CastOptions,
1213
SchemaFieldDescription,
1314
SchemaLazyDescription,
@@ -88,8 +89,16 @@ class Lazy<T, TContext = AnyObject, TFlags extends Flags = any>
8889
return this._resolve(options.value, options);
8990
}
9091

91-
cast(value: any, options?: CastOptions<TContext>): T {
92-
return this._resolve(value, options).cast(value, options);
92+
cast(value: any, options?: CastOptions<TContext>): T;
93+
cast(
94+
value: any,
95+
options?: CastOptionalityOptions<TContext>,
96+
): T | null | undefined;
97+
cast(
98+
value: any,
99+
options?: CastOptions<TContext> | CastOptionalityOptions<TContext>,
100+
): any {
101+
return this._resolve(value, options).cast(value, options as any);
93102
}
94103

95104
asNestedTest(options: NestedTestConfig) {

src/schema.ts

+29-9
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export interface CastOptions<C = {}> {
6969
path?: string;
7070
}
7171

72+
export interface CastOptionalityOptions<C = {}>
73+
extends Omit<CastOptions<C>, 'assert'> {
74+
/**
75+
* Whether or not to throw TypeErrors if casting fails to produce a valid type.
76+
* defaults to `true`. The `'ignore-optionality'` options is provided as a migration
77+
* path from pre-v1 where `schema.nullable().required()` was allowed. When provided
78+
* cast will only throw for values that are the wrong type *not* including `null` and `undefined`
79+
*/
80+
assert: 'ignore-optionality';
81+
}
82+
7283
export type RunTest = (
7384
opts: TestOptions,
7485
panic: PanicCallback,
@@ -328,19 +339,33 @@ export default abstract class Schema<
328339
/**
329340
* Run the configured transform pipeline over an input value.
330341
*/
331-
cast(value: any, options: CastOptions<TContext> = {}): this['__outputType'] {
342+
cast(value: any, options?: CastOptions<TContext>): this['__outputType'];
343+
cast(
344+
value: any,
345+
options: CastOptionalityOptions<TContext>,
346+
): this['__outputType'] | null | undefined;
347+
cast(
348+
value: any,
349+
options: CastOptions<TContext> | CastOptionalityOptions<TContext> = {},
350+
): this['__outputType'] {
332351
let resolvedSchema = this.resolve({
333352
value,
334353
...options,
335354
// parent: options.parent,
336355
// context: options.context,
337356
});
357+
let allowOptionality = options.assert === 'ignore-optionality';
338358

339-
let result = resolvedSchema._cast(value, options);
359+
let result = resolvedSchema._cast(value, options as any);
340360

341361
if (options.assert !== false && !resolvedSchema.isType(result)) {
362+
if (allowOptionality && isAbsent(result)) {
363+
return result as any;
364+
}
365+
342366
let formattedValue = printValue(value);
343367
let formattedResult = printValue(result);
368+
344369
throw new TypeError(
345370
`The value of ${
346371
options.path || 'field'
@@ -523,8 +548,7 @@ export default abstract class Schema<
523548
validate(
524549
value: any,
525550
options?: ValidateOptions<TContext>,
526-
): Promise<this['__outputType']>;
527-
validate(value: any, options?: ValidateOptions<TContext>): any {
551+
): Promise<this['__outputType']> {
528552
let schema = this.resolve({ ...options, value });
529553

530554
return new Promise((resolve, reject) =>
@@ -537,16 +561,12 @@ export default abstract class Schema<
537561
},
538562
(errors, validated) => {
539563
if (errors.length) reject(new ValidationError(errors!, validated));
540-
else resolve(validated);
564+
else resolve(validated as this['__outputType']);
541565
},
542566
),
543567
);
544568
}
545569

546-
validateSync(
547-
value: any,
548-
options?: ValidateOptions<TContext>,
549-
): this['__outputType'];
550570
validateSync(
551571
value: any,
552572
options?: ValidateOptions<TContext>,

src/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ResolveOptions } from './Condition';
22
import type {
33
AnySchema,
4+
CastOptionalityOptions,
45
CastOptions,
56
SchemaFieldDescription,
67
SchemaSpec,
@@ -18,6 +19,8 @@ export interface ISchema<T, C = AnyObject, F extends Flags = any, D = any> {
1819
__default: D;
1920

2021
cast(value: any, options?: CastOptions<C>): T;
22+
cast(value: any, options: CastOptionalityOptions<C>): T | null | undefined;
23+
2124
validate(value: any, options?: ValidateOptions<C>): Promise<T>;
2225

2326
asNestedTest(config: NestedTestConfig): Test;

test/mixed.ts

+10
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ describe('Mixed Types ', () => {
8787
);
8888
});
8989

90+
it('should allow missing values with the "ignore-optionality" option', () => {
91+
expect(
92+
string().required().cast(null, { assert: 'ignore-optionality' }),
93+
).toBe(null);
94+
95+
expect(
96+
string().required().cast(undefined, { assert: 'ignore-optionality' }),
97+
).toBe(undefined);
98+
});
99+
90100
it('should warn about null types', async () => {
91101
await expect(string().strict().validate(null)).rejects.toThrowError(
92102
/this cannot be null/,

test/types/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ Mixed: {
100100
type: 'string',
101101
check: (value): value is string => typeof value === 'string',
102102
});
103+
104+
// $ExpectType string
105+
mixed<string>().defined().cast('', { assert: true });
106+
107+
// $ExpectType string | null | undefined
108+
mixed<string>().defined().cast('', { assert: 'ignore-optionality' });
103109
}
104110

105111
Strings: {

0 commit comments

Comments
 (0)