Skip to content

Commit

Permalink
Fix masking within unions (#1251)
Browse files Browse the repository at this point in the history
* 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 <dimi.kot.code@gmail.com>
  • Loading branch information
arturmuller and dimikot authored Jun 30, 2024
1 parent 88563ad commit 8232269
Show file tree
Hide file tree
Showing 4 changed files with 56 additions and 25 deletions.
15 changes: 11 additions & 4 deletions src/struct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export class Struct<T = unknown, S = unknown> {

/**
* 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 {
Expand All @@ -97,15 +98,17 @@ export class Struct<T = unknown, S = unknown> {
* 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] {
Expand Down Expand Up @@ -209,12 +212,16 @@ export function validate<T, S>(

/**
* 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<any>
path: Array<any>
mask?: boolean
}

/**
Expand Down
28 changes: 24 additions & 4 deletions src/structs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,25 @@ export function object<S extends ObjectSchema>(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
},
})
}
Expand Down Expand Up @@ -499,9 +516,12 @@ export function union<A extends AnyStruct, B extends AnyStruct[]>(
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
}
Expand Down
16 changes: 1 addition & 15 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,24 +131,10 @@ export function* run<T, S>(
} = {}
): 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'
Expand Down
22 changes: 20 additions & 2 deletions test/api/mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
StructError,
array,
type,
union,
} from '../../src'

describe('mask', () => {
Expand Down Expand Up @@ -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(),
Expand Down

0 comments on commit 8232269

Please sign in to comment.