Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v0.31.0-rc.5] The internal function _stringify has a problem #608

Closed
devcaeg opened this issue May 27, 2024 · 23 comments
Closed

[v0.31.0-rc.5] The internal function _stringify has a problem #608

devcaeg opened this issue May 27, 2024 · 23 comments
Assignees
Labels
question Further information is requested

Comments

@devcaeg
Copy link

devcaeg commented May 27, 2024

The internal function _stringify has a problem. It may happen that constructor is undefined, so when trying to access constructor.name an error is thrown because name cannot be accessed since constructor is `undefined.

export function _stringify(input: unknown): string {
let type = typeof input;
if (type === 'object') {
type = input ? Object.getPrototypeOf(input).constructor.name : 'null';
}
return type === 'string'
? `"${input}"`
: type === 'number' || type === 'bigint' || type === 'boolean'
? `${input}`
: type;
}

@devcaeg
Copy link
Author

devcaeg commented May 27, 2024

Perhaps the solution is to use Object.prototype.toString.call(input).slice(8, -1) this will return Object, String, Number, Function, etc.

@fabian-hiller
Copy link
Owner

Thank you for reporting this issue. How can I reproduce it? I thought due to type === 'object' and Object.getPrototypeOf(input) it is always defined because every object has a constructor.

@fabian-hiller fabian-hiller self-assigned this May 27, 2024
@fabian-hiller fabian-hiller added the question Further information is requested label May 27, 2024
@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

For example, if an object is created with Object.create(null) the constructor will not be defined. This also happens with objects whose prototype has been modified.

const test1 = Object.create(null);
console.log(typeof test1, test1.constructor); // "object", "undefined"

function Empty() {}
Empty.prototype = Object.create(null);

const test2 = new Empty();
console.log(typeof test2, test2.constructor); // "object", "undefined"

@fabian-hiller
Copy link
Owner

Nice catch! Thank you! Something like this could work:

type = (input && Object.getPrototypeOf(input)?.constructor.name) || 'null';

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

Almost, it should actually be type = (input && Object.getPrototypeOf(input)?.constructor?.name) || 'null'; Since Object.getPrototypeOf(input) can be undefined or it can also be an empty object {}.

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

I have tested this change in my project but this change brings other problems. As you can see, the input actually complies with schema but safeParse says it does not.

const Empty = function () {};
Empty.prototype = Object.create(null);

const schema = object({
  id: string(),
});

const input = new Empty();
input.id = 'abc';

const validation = safeParse(schema, input);

console.log(validation);
{
  typed: false,
  success: false,
  output: {
    id: "123",
  },
  issues: [
    {
      kind: "schema",
      type: "object",
      input: [Object ...],
      expected: "Object",
      received: "null",
      message: "Invalid type: Expected Object but received null",
      requirement: undefined,
      path: undefined,
      issues: undefined,
      lang: undefined,
      abortEarly: undefined,
      abortPipeEarly: undefined,
      skipPipe: undefined,
    }
  ],
}

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

Although it may seem like an isolated case where constructor is not defined, in a real project, especially when using multiple libraries, this issue tends to occur frequently.

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

Sorry for so many comments, but I think issue #602 is related to this problem. Since if we force type to be Object you will receive the error Invalid type: Expected Object but received Object.

@fabian-hiller
Copy link
Owner

fabian-hiller commented May 28, 2024

Almost, it should actually be type = (input && Object.getPrototypeOf(input)?.constructor?.name) || 'null'; Since Object.getPrototypeOf(input) can be undefined or it can also be an empty object {}.

Can you provide me sample input where Object.getPrototypeOf(input).constructor is undefined?

I have tested this change in my project but this change brings other problems. As you can see, the input actually complies with schema but safeParse says it does not.

This is probably related to our input.constructor === Object check. What do you think about my comment here?

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

Yes, the problem is this line.

if (input && typeof input === 'object' && input.constructor === Object) {

Here we could use Object.prototype.toString.call(input).slice(8, -1) === 'Object' and you could remove the typeof and input.

if (Object.prototype.toString.call(input).slice(8, -1) === 'Object') {

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

Can you provide me sample input where Object.getPrototypeOf(input).constructor is undefined?

Sorry, it is not undefiend it is null.

const test1 = Object.create(null);
console.log(Object.getPrototypeOf(test1)); // null

function Empty() {}
Empty.prototype = Object.create(null);

const test2 = new Empty();
console.log(Object.getPrototypeOf(test2)); // {}

@fabian-hiller
Copy link
Owner

Here we could use Object.prototype.toString.call(input).slice(8, -1) === 'Object' and you could remove the typeof and input.

I don't know if I want to change the code this way but I will think about it. If we stick to the current implementation we probably should change input.constructor === Object to Object.getPrototypeOf(input)?.constructor === Object.

Can you provide me sample input where Object.getPrototypeOf(input).constructor is undefined?

Sorry, it is not undefiend it is null.

I mean Object.getPrototypeOf(input).constructor and not Object.getPrototypeOf(input). You recommended to add a second ? to Object.getPrototypeOf(input)?.constructor?.name and I want to understand why do you think that this is necessary.

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

I mean Object.getPrototypeOf(input).constructor and not Object.getPrototypeOf(input). You recommended to add a second ? to Object.getPrototypeOf(input)?.constructor?.name and I want to understand why do you think that this is necessary.

The first ? is useful in cases where Object.getPrototypeOf(input) is null but there are also cases where Object.getPrototypeOf(input) is an empty object {} so the first ? has no effect, therefore trying to access the name property of the constructor will throw error since the constructor property does not exist on an empty object {}.
For this reason, it is necessary to use two ?.

function Empty() {}
Empty.prototype = Object.create(null);

const test2 = new Empty();

Object.getPrototypeOf(test2); // {}
Object.getPrototypeOf(test2)?.constructor; // undefiend
Object.getPrototypeOf(test2)?.constructor.name; // TypeError: Cannot read properties of undefined (reading 'name')
Object.getPrototypeOf(test2)?.constructor?.name; // undefined

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

I don't know if I want to change the code this way but I will think about it. If we stick to the current implementation we probably should change input.constructor === Object to Object.getPrototypeOf(input)?.constructor === Object.

The problem with using Object.getPrototypeOf(input)?.constructor === Object is that it does not solve the problem in several cases, for example, in the two examples I mentioned above, and it will not solve it in Vercel Edge either.

For example, the fast-querystring library uses

function Empty() {}
Empty.prototype = Object.create(null);

so if with Valibot you wanted to validate this, it would actually give an error.

@EltonLobo07
Copy link
Contributor

As far I as know, Object.getPrototypeOf can return a valid object or null, thats why you will need the first optional chaining operator. [[Prototype]].constructor can be anything as programmers have the freedom to set it (check the example below). It is not common but it is possible. So the second optional chanining operator can help in those uncommon cases.

class Box<T> {
    #value: T;

    constructor(value: T) {
        this.#value = value;
    }

    getValue() {
        return this.#value;
    }

    setValue(newValue: T) {
        this.#value = newValue;
    }
}

const box = new Box("Hello world");
Object.setPrototypeOf(box, {constructor: null});
_stringify(box); // Runtime error

@fabian-hiller
Copy link
Owner

In conclusion, it seems to be best to remove the plain object check and only check input && typeof input === 'object', as there are so many possible edge cases. What is your opinion on this? This is how we implemented it before v0.31.0.

@fabian-hiller
Copy link
Owner

I think I will remove it for now until we find a better solution.

@fabian-hiller
Copy link
Owner

The only solution I see is your recommendation @devcaeg. We could change the type check in object to Object.prototype.toString.call(input).slice(8, -1) === 'Object'. Are there any drawbacks? Can developers manipulate what this returns?

Should we also change _stringify to type = input ? Object.prototype.toString.call(input).slice(8, -1) : 'null'? How should we proceed with other schemas like array? Should we change the type checking to this approach as well to minimize the bundle size due to better compression?

@EltonLobo07
Copy link
Contributor

My reply #608 (comment) - Yes, I agree. I think input && typeof input === 'object' is the only check that cannot be manipulated by developers. It will always work as expected. But by doing so, specific object types will also get accepted (like Array, Date and so on), which is fine because technically specific objects still belong to the general object type. I would recommend using input && typeof input === 'object' as a temporary change until we can find a better solution.

@EltonLobo07
Copy link
Contributor

The only solution I see is your recommendation @devcaeg. We could change the type check in object to Object.prototype.toString.call(input).slice(8, -1) === 'Object'. Are there any drawbacks? Can developers manipulate what this returns?

Should we also change _stringify to type = input ? Object.prototype.toString.call(input).slice(8, -1) : 'null'? How should we proceed with other schemas like array? Should we change the type checking to this approach as well to minimize the bundle size due to better compression?

toString checks are unreliable as developers can customize the string returned by the toString function call by setting [Symbol.toStringTag] as shown in the examples below. So I don't think relying on toString checks is a good idea.

class Box<T> {
    #value: T;

    constructor(value: T) {
        this.#value = value;
    }

    getValue() {
        return this.#value;
    }

    setValue(newValue: T) {
        this.#value = newValue;
    }

    get [Symbol.toStringTag]() {
        return "SomethingBox";
    }
}

// Custom object type
console.log(Object.prototype.toString.call(new Box("Hello world"))); // [object SomethingBox]

const arrayPrototype = Object.getPrototypeOf([]);
arrayPrototype[Symbol.toStringTag] = "xyz";

// JS provided complex object type
console.log(Object.prototype.toString.call([1, 2, 3])); // [object xyz]

// JS provided plain object
const message = {
    value: "Hi",
    [Symbol.toStringTag]: "Message"
};

console.log(Object.prototype.toString.call(message)); // [object Message]

@devcaeg
Copy link
Author

devcaeg commented May 28, 2024

@fabian-hiller
Copy link
Owner

fabian-hiller commented May 28, 2024

Due to our small bundle size, Zods solution does not work for us. 🫤

@fabian-hiller
Copy link
Owner

This is fixed in v0.31.0-rc.6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants