From 82322690d23469931860ee0f9c34ce497070d1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C3=BCller?= Date: Sun, 30 Jun 2024 22:23:06 +0200 Subject: [PATCH] Fix masking within unions (#1251) * fix mask() working incorrectly with union() when an alternative object contains extra unknown props * Annotate mask behaviour in object coercer * Update new tests to be compatible with Vitest --------- Co-authored-by: Dimi Kot --- src/struct.ts | 15 +++++++++++---- src/structs/types.ts | 28 ++++++++++++++++++++++++---- src/utils.ts | 16 +--------------- test/api/mask.test.ts | 22 ++++++++++++++++++++-- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/struct.ts b/src/struct.ts index 9a5c37ec..e3a94f28 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -86,7 +86,8 @@ export class Struct { /** * Mask a value, coercing and validating it, but returning only the subset of - * properties defined by the struct's schema. + * properties defined by the struct's schema. Masking applies recursively to + * props of `object` structs only. */ mask(value: unknown, message?: string): T { @@ -97,15 +98,17 @@ export class Struct { * Validate a value with the struct's validation logic, returning a tuple * representing the result. * - * You may optionally pass `true` for the `withCoercion` argument to coerce + * You may optionally pass `true` for the `coerce` argument to coerce * the value before attempting to validate it. If you do, the result will - * contain the coerced result when successful. + * contain the coerced result when successful. Also, `mask` will turn on + * masking of the unknown `object` props recursively if passed. */ validate( value: unknown, options: { coerce?: boolean + mask?: boolean message?: string } = {} ): [StructError, undefined] | [undefined, T] { @@ -209,12 +212,16 @@ export function validate( /** * A `Context` contains information about the current location of the - * validation inside the initial input value. + * validation inside the initial input value. It also carries `mask` + * since it's a run-time flag determining how the validation was invoked + * (via `mask()` or via `validate()`), plus it applies recursively + * to all of the nested structs. */ export type Context = { branch: Array path: Array + mask?: boolean } /** diff --git a/src/structs/types.ts b/src/structs/types.ts index 19493104..372aa5b1 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -319,8 +319,25 @@ export function object(schema?: S): any { isObject(value) || `Expected an object, but received: ${print(value)}` ) }, - coercer(value) { - return isObject(value) ? { ...value } : value + coercer(value, ctx) { + if (!isObject(value) || Array.isArray(value)) { + return value + } + + const coerced = { ...value } + + // The `object` struct has special behaviour enabled by the mask flag. + // When masking, properties that are not in the schema are deleted from + // the coerced object instead of eventually failing validaiton. + if (ctx.mask && schema) { + for (const key in coerced) { + if (schema[key] === undefined) { + delete coerced[key] + } + } + } + + return coerced }, }) } @@ -499,9 +516,12 @@ export function union( return new Struct({ type: 'union', schema: null, - coercer(value) { + coercer(value, ctx) { for (const S of Structs) { - const [error, coerced] = S.validate(value, { coerce: true }) + const [error, coerced] = S.validate(value, { + coerce: true, + mask: ctx.mask, + }) if (!error) { return coerced } diff --git a/src/utils.ts b/src/utils.ts index db69aea1..aa80983e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -131,24 +131,10 @@ export function* run( } = {} ): IterableIterator<[Failure, undefined] | [undefined, T]> { const { path = [], branch = [value], coerce = false, mask = false } = options - const ctx: Context = { path, branch } + const ctx: Context = { path, branch, mask } if (coerce) { value = struct.coercer(value, ctx) - - if ( - mask && - struct.type !== 'type' && - isObject(struct.schema) && - isObject(value) && - !Array.isArray(value) - ) { - for (const key in value) { - if (struct.schema[key] === undefined) { - delete value[key] - } - } - } } let status: 'valid' | 'not_refined' | 'not_valid' = 'valid' diff --git a/test/api/mask.test.ts b/test/api/mask.test.ts index 754fac30..fee3d99f 100644 --- a/test/api/mask.test.ts +++ b/test/api/mask.test.ts @@ -7,6 +7,7 @@ import { StructError, array, type, + union, } from '../../src' describe('mask', () => { @@ -44,19 +45,36 @@ describe('mask', () => { it('masking of a nested type', () => { const S = object({ id: string(), - sub: array(type({ prop: string() })), + sub: array( + type({ prop: string(), defaultedProp: defaulted(string(), '42') }) + ), + union: array(union([object({ prop: string() }), type({ k: string() })])), }) const value = { id: '1', unknown: true, sub: [{ prop: '2', unknown: true }], + union: [ + { prop: '3', unknown: true }, + { k: '4', unknown: true }, + ], } expect(mask(value, S)).toStrictEqual({ id: '1', - sub: [{ prop: '2', unknown: true }], + sub: [{ prop: '2', unknown: true, defaultedProp: '42' }], + union: [{ prop: '3' }, { k: '4', unknown: true }], }) }) + it('masking succeeds for objects with extra props in union', () => { + const S = union([ + object({ a: string(), defaultedProp: defaulted(string(), '42') }), + object({ b: string() }), + ]) + const value = { a: '1', extraProp: 42 } + expect(mask(value, S)).toStrictEqual({ a: '1', defaultedProp: '42' }) + }) + it('masking of a top level type and nested object', () => { const S = type({ id: string(),