Skip to content

Commit 8232269

Browse files
arturmullerdimikot
andauthored
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 <dimi.kot.code@gmail.com>
1 parent 88563ad commit 8232269

File tree

4 files changed

+56
-25
lines changed

4 files changed

+56
-25
lines changed

src/struct.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export class Struct<T = unknown, S = unknown> {
8686

8787
/**
8888
* Mask a value, coercing and validating it, but returning only the subset of
89-
* properties defined by the struct's schema.
89+
* properties defined by the struct's schema. Masking applies recursively to
90+
* props of `object` structs only.
9091
*/
9192

9293
mask(value: unknown, message?: string): T {
@@ -97,15 +98,17 @@ export class Struct<T = unknown, S = unknown> {
9798
* Validate a value with the struct's validation logic, returning a tuple
9899
* representing the result.
99100
*
100-
* You may optionally pass `true` for the `withCoercion` argument to coerce
101+
* You may optionally pass `true` for the `coerce` argument to coerce
101102
* the value before attempting to validate it. If you do, the result will
102-
* contain the coerced result when successful.
103+
* contain the coerced result when successful. Also, `mask` will turn on
104+
* masking of the unknown `object` props recursively if passed.
103105
*/
104106

105107
validate(
106108
value: unknown,
107109
options: {
108110
coerce?: boolean
111+
mask?: boolean
109112
message?: string
110113
} = {}
111114
): [StructError, undefined] | [undefined, T] {
@@ -209,12 +212,16 @@ export function validate<T, S>(
209212

210213
/**
211214
* A `Context` contains information about the current location of the
212-
* validation inside the initial input value.
215+
* validation inside the initial input value. It also carries `mask`
216+
* since it's a run-time flag determining how the validation was invoked
217+
* (via `mask()` or via `validate()`), plus it applies recursively
218+
* to all of the nested structs.
213219
*/
214220

215221
export type Context = {
216222
branch: Array<any>
217223
path: Array<any>
224+
mask?: boolean
218225
}
219226

220227
/**

src/structs/types.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,25 @@ export function object<S extends ObjectSchema>(schema?: S): any {
319319
isObject(value) || `Expected an object, but received: ${print(value)}`
320320
)
321321
},
322-
coercer(value) {
323-
return isObject(value) ? { ...value } : value
322+
coercer(value, ctx) {
323+
if (!isObject(value) || Array.isArray(value)) {
324+
return value
325+
}
326+
327+
const coerced = { ...value }
328+
329+
// The `object` struct has special behaviour enabled by the mask flag.
330+
// When masking, properties that are not in the schema are deleted from
331+
// the coerced object instead of eventually failing validaiton.
332+
if (ctx.mask && schema) {
333+
for (const key in coerced) {
334+
if (schema[key] === undefined) {
335+
delete coerced[key]
336+
}
337+
}
338+
}
339+
340+
return coerced
324341
},
325342
})
326343
}
@@ -499,9 +516,12 @@ export function union<A extends AnyStruct, B extends AnyStruct[]>(
499516
return new Struct({
500517
type: 'union',
501518
schema: null,
502-
coercer(value) {
519+
coercer(value, ctx) {
503520
for (const S of Structs) {
504-
const [error, coerced] = S.validate(value, { coerce: true })
521+
const [error, coerced] = S.validate(value, {
522+
coerce: true,
523+
mask: ctx.mask,
524+
})
505525
if (!error) {
506526
return coerced
507527
}

src/utils.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -131,24 +131,10 @@ export function* run<T, S>(
131131
} = {}
132132
): IterableIterator<[Failure, undefined] | [undefined, T]> {
133133
const { path = [], branch = [value], coerce = false, mask = false } = options
134-
const ctx: Context = { path, branch }
134+
const ctx: Context = { path, branch, mask }
135135

136136
if (coerce) {
137137
value = struct.coercer(value, ctx)
138-
139-
if (
140-
mask &&
141-
struct.type !== 'type' &&
142-
isObject(struct.schema) &&
143-
isObject(value) &&
144-
!Array.isArray(value)
145-
) {
146-
for (const key in value) {
147-
if (struct.schema[key] === undefined) {
148-
delete value[key]
149-
}
150-
}
151-
}
152138
}
153139

154140
let status: 'valid' | 'not_refined' | 'not_valid' = 'valid'

test/api/mask.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
StructError,
88
array,
99
type,
10+
union,
1011
} from '../../src'
1112

1213
describe('mask', () => {
@@ -44,19 +45,36 @@ describe('mask', () => {
4445
it('masking of a nested type', () => {
4546
const S = object({
4647
id: string(),
47-
sub: array(type({ prop: string() })),
48+
sub: array(
49+
type({ prop: string(), defaultedProp: defaulted(string(), '42') })
50+
),
51+
union: array(union([object({ prop: string() }), type({ k: string() })])),
4852
})
4953
const value = {
5054
id: '1',
5155
unknown: true,
5256
sub: [{ prop: '2', unknown: true }],
57+
union: [
58+
{ prop: '3', unknown: true },
59+
{ k: '4', unknown: true },
60+
],
5361
}
5462
expect(mask(value, S)).toStrictEqual({
5563
id: '1',
56-
sub: [{ prop: '2', unknown: true }],
64+
sub: [{ prop: '2', unknown: true, defaultedProp: '42' }],
65+
union: [{ prop: '3' }, { k: '4', unknown: true }],
5766
})
5867
})
5968

69+
it('masking succeeds for objects with extra props in union', () => {
70+
const S = union([
71+
object({ a: string(), defaultedProp: defaulted(string(), '42') }),
72+
object({ b: string() }),
73+
])
74+
const value = { a: '1', extraProp: 42 }
75+
expect(mask(value, S)).toStrictEqual({ a: '1', defaultedProp: '42' })
76+
})
77+
6078
it('masking of a top level type and nested object', () => {
6179
const S = type({
6280
id: string(),

0 commit comments

Comments
 (0)