Skip to content

Commit

Permalink
Merge 2d52fc5 into 6d7927d
Browse files Browse the repository at this point in the history
  • Loading branch information
ForbesLindesay committed May 4, 2022
2 parents 6d7927d + 2d52fc5 commit 4435197
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 43 deletions.
2 changes: 1 addition & 1 deletion src/asynccontract.ts
Expand Up @@ -48,7 +48,7 @@ export function AsyncContract<A extends [any, ...any[]] | [], Z>(
);
}
return returnedPromise.then(value => {
const result = innerGuard(returnType, value, createGuardVisitedState(), false);
const result = innerGuard(returnType, value, createGuardVisitedState(), false, false);
if (result) {
throw new ValidationError(result);
}
Expand Down
2 changes: 1 addition & 1 deletion src/contract.ts
Expand Up @@ -37,7 +37,7 @@ export function Contract<A extends [any, ...any[]] | [], Z>(
}
}
const rawResult = f(...args);
const result = innerGuard(returnType, rawResult, createGuardVisitedState(), false);
const result = innerGuard(returnType, rawResult, createGuardVisitedState(), false, false);
if (result) {
throw new ValidationError(result);
}
Expand Down
24 changes: 16 additions & 8 deletions src/runtype.ts
Expand Up @@ -72,6 +72,7 @@ export interface InternalValidation<TParsed> {
sealed?: SealedState,
) => Failure | undefined,
sealed: SealedState,
isOptionalTest: boolean,
) => Failure | undefined;
/**
* serialize
Expand Down Expand Up @@ -262,7 +263,7 @@ export function create<TConfig extends Codec<any>>(
...config,
tag,
assert(x: any): asserts x is Static<TConfig> {
const validated = innerGuard(A, x, createGuardVisitedState(), false);
const validated = innerGuard(A, x, createGuardVisitedState(), false, false);
if (validated) {
throw new ValidationError(validated);
}
Expand Down Expand Up @@ -320,7 +321,7 @@ export function create<TConfig extends Codec<any>>(
}

function test(x: any): x is Static<TConfig> {
const validated = innerGuard(A, x, createGuardVisitedState(), false);
const validated = innerGuard(A, x, createGuardVisitedState(), false, false);
return validated === undefined;
}
}
Expand Down Expand Up @@ -390,7 +391,7 @@ export function mapValidationPlaceholder<T, S>(
return (
(result.success &&
extraGuard &&
innerGuard(extraGuard, result.value, createGuardVisitedState(), false)) ||
innerGuard(extraGuard, result.value, createGuardVisitedState(), false, true)) ||
result
);
}
Expand Down Expand Up @@ -430,11 +431,12 @@ function innerMapValidationPlaceholder(
const guardFailure =
unwrapResult.success &&
extraGuard &&
innerGuard(extraGuard, unwrapResult.value, createGuardVisitedState(), false);
innerGuard(extraGuard, unwrapResult.value, createGuardVisitedState(), false, true);
cache = guardFailure || unwrapResult;
} else {
const guardFailure =
extraGuard && innerGuard(extraGuard, result.value, createGuardVisitedState(), false);
extraGuard &&
innerGuard(extraGuard, result.value, createGuardVisitedState(), false, true);
cache = guardFailure || result;
}

Expand Down Expand Up @@ -560,6 +562,7 @@ export function innerGuard(
value: any,
$visited: OpaqueGuardVisitedState,
sealed: SealedState,
isOptionalTest: boolean,
): Failure | undefined {
const visited = unwrapGuardVisitedState($visited);
const validator = targetType[internal];
Expand All @@ -569,12 +572,17 @@ export function innerGuard(
visited.set(targetType, (visited.get(targetType) || new Set()).add(value));
}
if (validator.t) {
return validator.t(value, (t, v, s) => innerGuard(t, v, $visited, s ?? sealed), sealed);
return validator.t(
value,
(t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest),
sealed,
isOptionalTest,
);
}
let result = validator.p(
value,
(t, v, s) => innerGuard(t, v, $visited, s ?? sealed) || success(v as any),
(t, v, s) => innerGuard(t, v, $visited, s ?? sealed) || success(v as any),
(t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest) || success(v as any),
(t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest) || success(v as any),
't',
sealed,
);
Expand Down
177 changes: 155 additions & 22 deletions src/types/ParsedValue.spec.ts
Expand Up @@ -10,6 +10,8 @@ import {
Union,
Tuple,
Codec,
Sealed,
showError,
} from '..';
import show from '../show';
import { InstanceOf } from './instanceof';
Expand Down Expand Up @@ -42,9 +44,9 @@ test('TrimmedString', () => {
`);

expect(() => TrimmedString.assert(' foo bar ')).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign \\" foo bar \\" to WithConstraint<string>
Expected the string to be trimmed, but this one has whitespace"
`);
"Unable to assign \\" foo bar \\" to WithConstraint<string>
Expected the string to be trimmed, but this one has whitespace"
`);
expect(() => TrimmedString.assert('foo bar')).not.toThrow();
});

Expand All @@ -65,9 +67,9 @@ test('DoubledNumber', () => {
`);

expect(() => DoubledNumber.assert(11)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign 11 to WithConstraint<number>
Expected an even number"
`);
"Unable to assign 11 to WithConstraint<number>
Expected an even number"
`);
expect(() => DoubledNumber.assert(12)).not.toThrow();

expect(DoubledNumber.safeSerialize(10)).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -98,9 +100,9 @@ test('DoubledNumber - 2', () => {
`);

expect(() => DoubledNumber.assert(11)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign 11 to WithConstraint<number>
Expected an even number"
`);
"Unable to assign 11 to WithConstraint<number>
Expected an even number"
`);
expect(() => DoubledNumber.assert(12)).not.toThrow();

expect(DoubledNumber.safeSerialize(10)).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -180,10 +182,10 @@ test('Upgrade Example', () => {
`);
expect(() => Shape.serialize({ version: 1, size: 20 } as any))
.toThrowErrorMatchingInlineSnapshot(`
"Unable to assign {version: 1, size: 20} to { version: 2; width: number; height: number; }
The types of \\"version\\" are not compatible
Expected 2, but was 1"
`);
"Unable to assign {version: 1, size: 20} to { version: 2; width: number; height: number; }
The types of \\"version\\" are not compatible
Expected 2, but was 1"
`);
});

test('URL', () => {
Expand Down Expand Up @@ -324,11 +326,11 @@ test('Handle Being Within Cycles', () => {

expect(() => RecursiveType.assert(parsed)).not.toThrow();
expect(() => RecursiveType.assert(serialized)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign [\\" hello world \\", [\\" hello world \\" ... ]] to [TrimmedString, CIRCULAR tuple]
The types of [0] are not compatible
Unable to assign \\" hello world \\" to WithConstraint<string>
Expected the string to be trimmed, but this one has whitespace"
`);
"Unable to assign [\\" hello world \\", [\\" hello world \\" ... ]] to [TrimmedString, CIRCULAR tuple]
The types of [0] are not compatible
Unable to assign \\" hello world \\" to WithConstraint<string>
Expected the string to be trimmed, but this one has whitespace"
`);
});

test('Handle Being Outside Cycles', () => {
Expand Down Expand Up @@ -368,10 +370,10 @@ test('Handle Being Outside Cycles', () => {

expect(() => RecursiveType.assert(parsed)).not.toThrow();
expect(() => RecursiveType.assert(serialized)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign [\\"hello world\\", [\\"hello world\\" ... ]] to (CIRCULAR array)[]
The types of [0] are not compatible
Expected an Array, but was \\"hello world\\""
`);
"Unable to assign [\\"hello world\\", [\\"hello world\\" ... ]] to (CIRCULAR array)[]
The types of [0] are not compatible
Expected an Array, but was \\"hello world\\""
`);
});

test('Handle Being Outside Cycles - objects', () => {
Expand Down Expand Up @@ -488,3 +490,134 @@ test('Fails when cycles modify types', () => {
}
`);
});

test('Handles partial tests on parse', () => {
// Result type doesn't support `undefined` when parsing
// but only because we haven't implemented that test
const ResultType = Sealed(
Union(
Object({ hello: Literal('world') }),
ParsedValue(Object({}), {
parse(_value) {
return { success: true, value: undefined };
},
}),
),
{ deep: true },
);
const JsonType = ParsedValue(String, {
test: ResultType,
parse(value) {
try {
return ResultType.safeParse(JSON.parse(value));
} catch (ex) {
return {
success: false,
message: `Expected a JSON string but got ${JSON.stringify(value)}`,
};
}
},
serialize(value) {
const r = ResultType.safeSerialize(value);
return r.success ? { success: true, value: JSON.stringify(r.value) } : r;
},
});
// The test supports { hello 'world' } as one of the values in the union
expect(() => ResultType.assert({ hello: 'world' })).not.toThrow();
expect(() => JsonType.assert({ hello: 'world' })).not.toThrow();

// although undefined can be returned from the "parse", it is not supported by the test,
// but this is only because it is not implemented
expect(() => ResultType.assert(undefined)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign undefined to { hello: \\"world\\"; } | ParsedValue<{}>
Unable to assign undefined to ParsedValue<{}>
ParsedValue<{}> does not support Runtype.test
And unable to assign undefined to Object"
`);
expect(() => JsonType.assert(undefined)).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign undefined to { hello: \\"world\\"; } | ParsedValue<{}>
Unable to assign undefined to ParsedValue<{}>
ParsedValue<{}> does not support Runtype.test
And unable to assign undefined to Object"
`);

// We used Sealed, so extra properties are not allowed
expect(() => JsonType.assert({ hello: 'world', whatever: true }))
.toThrowErrorMatchingInlineSnapshot(`
"Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } | ParsedValue<{}>
Unable to assign {hello: \\"world\\", whatever: true} to ParsedValue<{}>
ParsedValue<{}> does not support Runtype.test
And unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unexpected property: whatever"
`);

// The basic parsing works
expect(JsonType.safeParse(`{"hello": "world"}`)).toEqual({
success: true,
value: { hello: 'world' },
});
// Parsing also works even if the test would fail because it is not
// implemented
expect(JsonType.safeParse(`{}`)).toEqual({
success: true,
value: undefined,
});

// We used Sealed, so extra properties are not allowed
expect(showError(JsonType.safeParse(`{"hello": "world", "whatever": true}`) as any))
.toMatchInlineSnapshot(`
"Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } | ParsedValue<{}>
Unable to assign {hello: \\"world\\", whatever: true} to {}
Unexpected property: hello
Unexpected property: whatever
And unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unexpected property: whatever"
`);

// We can serialize the normal object
expect(JsonType.safeSerialize({ hello: 'world' })).toEqual({
success: true,
value: `{"hello":"world"}`,
});
// We cannot serialize undefined because we didn't implement serialize for that value in the union
expect(JsonType.safeSerialize(undefined)).toEqual({
success: false,
message: 'Expected { hello: "world"; }, but was undefined',
});
// We used Sealed, so extra properties are not allowed
expect(showError(JsonType.safeSerialize({ hello: 'world', whatever: true } as any) as any))
.toMatchInlineSnapshot(`
"Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; }
Unexpected property: whatever"
`);

// We still apply normal tests post-parse, so you can still use the `test` to add
// extra constraints
const evenString = ParsedValue(String, {
test: Number.withConstraint(
value => (value % 2 === 0 ? true : `Expected an even number but got ${value}`),
{ name: `EvenNumber` },
),
parse(value) {
if (!/^\d+$/.test(value)) {
return {
success: false,
message: `Expected an even integer but got ${JSON.stringify(value)}`,
};
}
return { success: true, value: parseInt(value, 10) };
},
name: `EvenString`,
});
expect(evenString.safeParse('10')).toEqual({
success: true,
value: 10,
});
expect(showError(evenString.safeParse('9') as any)).toMatchInlineSnapshot(`
"Unable to assign 9 to EvenNumber
Expected an even number but got 9"
`);
});
12 changes: 7 additions & 5 deletions src/types/ParsedValue.ts
Expand Up @@ -4,10 +4,10 @@ import {
Static,
create,
Codec,
innerGuard,
createGuardVisitedState,
mapValidationPlaceholder,
assertRuntype,
innerGuard,
createGuardVisitedState,
} from '../runtype';
import show from '../show';
import { Never } from './never';
Expand Down Expand Up @@ -40,9 +40,11 @@ export function ParsedValue<TUnderlying extends RuntypeBase<unknown>, TParsed>(
config.test,
);
},
t(value, internalTest) {
t(value, internalTest, _sealed, isOptionalTest) {
return config.test
? internalTest(config.test, value)
: isOptionalTest
? undefined
: failure(
`${config.name || `ParsedValue<${show(underlying)}>`} does not support Runtype.test`,
);
Expand All @@ -56,7 +58,7 @@ export function ParsedValue<TUnderlying extends RuntypeBase<unknown>, TParsed>(
);
}
const testResult = config.test
? innerGuard(config.test, value, createGuardVisitedState(), sealed)
? innerGuard(config.test, value, createGuardVisitedState(), sealed, true)
: undefined;

if (testResult) {
Expand All @@ -76,7 +78,7 @@ export function ParsedValue<TUnderlying extends RuntypeBase<unknown>, TParsed>(
case 'p':
return underlying;
case 't':
return config.test ?? Never;
return config.test;
case 's':
return config.serialize ? config.test : Never;
}
Expand Down
16 changes: 11 additions & 5 deletions src/types/Sealed.spec.ts
Expand Up @@ -737,13 +737,19 @@ test(`Sealed - Deep`, () => {

(unionParsed as any).assert({ hello: 'a' });
expect(() => unionParsed.assert({ hello: 'a', world: 'b' })).toThrowErrorMatchingInlineSnapshot(`
"Unable to assign {hello: \\"a\\", world: \\"b\\"} to { x?: number; }
Unexpected property: world"
"Unable to assign {hello: \\"a\\", world: \\"b\\"} to ParsedValue<{ hello: string; world: string; }> | { hello: string; }
Unable to assign {hello: \\"a\\", world: \\"b\\"} to ParsedValue<{ hello: string; world: string; }>
ParsedValue<{ hello: string; world: string; }> does not support Runtype.test
And unable to assign {hello: \\"a\\", world: \\"b\\"} to { hello: string; }
Unexpected property: world"
`);
expect(() => unionParsed.assert({ hello: 'a', world: 'b', other: 'c' }))
.toThrowErrorMatchingInlineSnapshot(`
"Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to { x?: number; }
Unexpected property: world
Unexpected property: other"
"Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to ParsedValue<{ hello: string; world: string; }> | { hello: string; }
Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to ParsedValue<{ hello: string; world: string; }>
ParsedValue<{ hello: string; world: string; }> does not support Runtype.test
And unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to { hello: string; }
Unexpected property: world
Unexpected property: other"
`);
});

0 comments on commit 4435197

Please sign in to comment.