bug report: `s.validate(value, struct, { mask: true })` does not work?
edman opened this issue · comments
Hello, not sure I'm doing something wrong, but here's a snippet:
import * as s from 'superstruct';
const Person = s.object({ name: s.string() });
const value = { name: 'Joe', salary: 2000 };
const person1 = s.mask(value, Person);
const person2 = s.validate(value, Person);
const person3 = s.validate(value, Person, { coerce: true });
const person4 = s.validate(value, Person, { mask: true });
const person5 = s.validate(value, Person, { mask: true, coerce: true });
console.log(person1, person2, person3, person4, person5);
person1
works (as expected).
person2
does not work. The StructError
points out salary
should be never
(as expected).
person3
does not work, same error (as expected).
Then the unexpected parts come in.
person4
does not work, same error. The mask
option has no effect.
person5
does work. Somehow setting coerce
and mask
makes the mask take effect. But in this case the coercion also takes effect.
Is this the expected behavior?
@edman you are right that this behaviour is rather confusing. I would say that it is actually correct, but some changes could be made (definitely docs, maybe more) so that other people don't run into the same confusing sitation.
Why does this behaviour happen?
Masking only works when also coercing because a new (masked) object has to be created in order to avoid mutating the orignal supplied value.
Functions like validate
(without options), assert
, and is
are only inspecting a provided value without any modifications. For example:
const value = { name: "John" };
const [error, result] = validate(value, object({ name: string() }));
value === result // true
On the other hand, validate
(with { coerce: true }
), create
, and mask
actually create new values:
const value = { name: "John" };
const [error, result] = validate(value, object({ name: string() }), { coerce: true });
value === result // false
My guess is that mask: true
was not exactly intended to be public API (as it is not documented ), but -- as all of the core validation functions use validate
under the hood -- it was added as an "internal use" option and then leaked out because of TypeScript typings.
Possible fixes
@ianstormtaylor what do you think about these possible improvements to prevent this confusion in the future:
Masking only works when also coercing because a new (masked) object has to be created in order to avoid mutating the orignal supplied value.
It makes sense to create an object, but should the new object always be coerced?
I actually learned this now, and find it surprising that the mask applies defaults.
e.g. this works:
const Person = s.object({ name: s.defaulted(s.string(), 'Joe') });
const person = s.mask({}, Person); // person.name == 'Joe'
If this is intended the mask
name is misleading. It may create things that are not there, so it's not really a mask.
Based on how the implementation is written, I would wager that it is in fact intentional. The question whether the name or semantics of mask
are misleading is probably better left to the library author.
FWIW, to me, the example above does not seems surprising. The Person
object has an explicit default for the name
property, so it seems reasonable that it is created when mask
is called. No property would be added if not instructed to do so.
Given that mask has to create new objects to do its job (a kind of coercion), it seems somewhat intuitive that it will also coerce all its constituent parts to fit into shape. It is basically a variant of create
in this way.
No property would be added if not instructed to do so
I think confusion may come from whether a reader thinks that mask({}, Person)
is such an instruction to add properties or not.
I agree you need to create a new object to avoid mutating the input given to mask
, but the fact that the library coerces that new object the same way done in create
is not a necessity, it's an implementation choice.
In that sense, wouldn't it go against the "Unopinionated defaults" principle stated in the readme?
One way this can be less opinionated is to make coercion configurable or explicit at call site:
const Person = s.object({ name: s.defaulted(s.string(), 'Joe') });
const a = s.mask({ age: 13 }, Person, { coerce: true }) // { name: 'Joe' }
const b = s.create({}, Person, { coerce: false }) // throws
Yeah, I can kinda see where you're coming from, although I struggle to imagine a real-world use-case where this partial coercion inside of mask is useful. Keep in mind all these functions are recursive, so in a deeply nested object, what you're proposing would mean that you're jumping between coerced objects and uncoerced properties all the time.
Can you perhaps explain what are you trying to achieve that warrants this kind of behaviour?
For me, the beauty of the superstruct's core functions is that they map to problems I encounter in the real world all the time. Need to fit an object into shape (parse some props, set defaults, etc...) before I use it? Use create
. Need to safely slice-up a larger object into multiple smaller ones without left-over properties? Use mask
. Need to simply check something conforms? Use is
or assert
. The documentation is pretty clear what each of these do, which ones coerce, etc... If for some reason I wanted to do what (I think) you're suggesting, I would simply mask a struct that doesn't include any coercions.
Anyhow, I hope this explanation was useful to you. My goal was only to clarify the current implementation -- not to convince you it is correct.
API design questions are ultimately up to Ian who I already tagged above -- so let's see what he thinks.
I am closing this issue for now. If you have more detail about how mask could be useful without coercion with a real-world example I am happy to re-open.
For completion, Edman's example above could be achieved with validate
:
const person = object({ name: defaulted(string(), 'Joe') });
const [err1, data1] = validate({ age: 13 }, person, { coerce: true, mask: true });
// err1 is undefined, data1 is { name: 'Joe' }
const [err2, data2] = validate({}, person, { coerce: false });
// err2 is a StructError, data2 is undefined