ianstormtaylor / superstruct

A simple and composable way to validate data in JavaScript (and TypeScript).

Home Page:https://docs.superstructjs.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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:

  1. Add a mention of mask to validation docs and warn against usage without coerce: true. Since it is visible in typings, people are likely to discover it anyways, and so it might as well be documented.

  2. Change typings to disallow mask: true without coerce: true. Could look something like this:
    image

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