sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.56k stars 148 forks source link

Issue regarding nested objects when using Value.Default #896

Closed piyush-nudge closed 3 weeks ago

piyush-nudge commented 4 weeks ago

Hello @sinclairzx81 ,

Recently, we were using typebox for our request and response validations and we wanted to fill the default values where we weren't getting any input. For this usecase, I noticed some disparities with Value.Default() where in some cases it wasn't working correctly with nested objects. For example:

In this snippet:

import { Type } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';

export const request = Type.Object({
    query: Type.Object({
        pageState: Type.Optional(Type.String({ default: 'sample' })),
        all: Type.String({ default: 'false' }), // if false, return only parent tasks.
    }),
    limit: Type.Transform(Type.String({ default: '10' }))
        .Decode(value => Number(value))
        .Encode(value => value.toString()),
});

const obj = {
    query: {
        all: 'false'
    },
};

const defaultValues = Value.Default(request, obj);

console.log(defaultValues);

console.log(Value.Decode(request, defaultValues));

we are getting the required results for limit but not for pageState, i.e:

default values: {
  query: {
    all: "false",
  },
  limit: "10",
}
decoded values: {
  query: {
    all: "false",
  },
  limit: 10,
}

But, if we do the same thing for pageState and move it outside of the query object, it's behaving as expected:

import { Type } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';

// pageState moved out of the query object
export const request = Type.Object({
    pageState: Type.Optional(Type.String({ default: 'sample' })),
    query: Type.Object({
        all: Type.String({ default: 'false' }), // if false, return only parent tasks.
    }),
    limit: Type.Transform(Type.String({ default: '10' }))
        .Decode(value => Number(value))
        .Encode(value => value.toString()),
});

const obj = {
    query: {
        all: 'false'
    },
};

const defaultValues = Value.Default(request, obj);

console.log(defaultValues);

console.log(Value.Decode(request, defaultValues));

Results:

default values: {
  query: {
    all: "false",
  },
  pageState: "sample",
  limit: "10",
}
decoded values: {
  query: {
    all: "false",
  },
  pageState: "sample",
  limit: 10,
}

Is this an issue of recursiveness while using Default to map the default values provided during runtime?

joshuaavalon commented 4 weeks ago

@piyush-nudge From my experience, Type.Optional(Type.String({ default: 'sample' })) is setting the default value for Type.String() not Type.Optional().

There is no way to set the default value for Type.Optional(). But if you always use the default value, you can just remove the Type.Optional(). If you want to keep Type.Optional(), you can set the default value on parent's default value instead.

export const request = Type.Object({
  pageState: Type.Optional(Type.String()),
  query: Type.Object({
    all: Type.String({ default: 'false' }), // if false, return only parent tasks.
  }),
  limit: Type.Transform(Type.String({ default: '10' }))
    .Decode(value => Number(value))
    .Encode(value => value.toString())
}, { default: { pageState: 'sample' } });
piyush-nudge commented 4 weeks ago

@joshuaavalon Thanks for the clarification regarding Type.Optional() and default values. However, if we want to use the pageState inside the query object and wanted to use default values after removing the Type.Optional() property frompageState field, the value is still not as expected and throws an error when we try to use Value.Decode() on the request object:

Snippet:

import { Type } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';

export const request = Type.Object({
    query: Type.Object({
        pageState: Type.String({ default: 'sample' }),
        all: Type.String({ default: 'false' }),
    }),
    limit: Type.Transform(Type.String({ default: '10' }))
        .Decode(value => Number(value))
        .Encode(value => value.toString()),
});

const obj = {
    query: {
        all: 'false'
    },
};

Value.Default(request, obj);
console.log("default values:", obj);

Value.Decode(request, obj);
console.log("decoded values:", obj);

Logs and errors:

default values: {
  query: {
    all: "false",
  },
  limit: "10",
}
1 | /** The base Error type thrown for all TypeBox exceptions  */
2 | export class TypeBoxError extends Error {
3 |     constructor(message) {
4 |         super(message);
            ^
error: Unable to decode value as it does not match the expected schema
      at new TypeBoxError (server\node_modules\@sinclair\typebox\build\esm\type\error\error.mjs:4:9)
      at new TransformDecodeCheckError (server\node_modules\@sinclair\typebox\build\esm\value\transform\decode.mjs:24:9)
      at Decode (server\node_modules\@sinclair\typebox\build\esm\value\value\value.mjs:42:15)
      at server\test.ts:23:1

@sinclairzx81 Is it the expected behavior with Value.Default() because when we are keeping pageState in the top level of the request object, the default values are getting assigned correctly, but when we are adding another level of nesting (placing pageState inside the query object) the values are not getting assigned correctly. As you explained earlier, we can use a default object like this:

export const request = Type.Object({
  query: Type.Object({
    pageState: Type.String({ default: 'sample' }),
    all: Type.String({ default: 'false' }),
  }),
  limit: Type.Transform(Type.String({ default: '10' }))
    .Decode(value => Number(value))
    .Encode(value => value.toString())
}, { default: { query: { pageState: 'sample' } }});

But we are having larger objects for type validations and if it's possible to use Value.Default() with nested objects, it would be a life-saver, otherwise we have to assign whole default objects like the above snippet (I'm praying that there is some workaround to making Value.Default() work)

joshuaavalon commented 4 weeks ago

@piyush-nudge I think this is what you want.

import { Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

export const request = Type.Object({
  query: Type.Object({
    pageState: Type.String({ default: "sample" }),
    all: Type.String({ default: "false" })
  }, { default: {} }),
  limit: Type.Transform(Type.String({ default: "10" }))
    .Decode(value => Number(value))
    .Encode(value => value.toString())
});

const obj = { query: { all: "false" } };

Value.Default(request, obj);
console.log("default values:", obj);

const decoded = Value.Decode(request, obj);
console.log("decoded values:", decoded);
default values: { query: { all: 'false', pageState: 'sample' }, limit: '10' }
decoded values: { query: { all: 'false', pageState: 'sample' }, limit: 10 }

First, Value.Decode does not mutate the parameters. You have to use the return value. Second, it seems that it does not create default if the nested object does not have default value.

piyush-nudge commented 4 weeks ago

Thanks @joshuaavalon, this method worked for our usecase and now we are getting all the correct default values for the nested objects. Thanks for the quick reply!

sinclairzx81 commented 3 weeks ago

@joshuaavalon Hey, thanks for handling this one :) (that's super appreciated!)

@piyush-nudge will close up this issue for now, but if you have any follow up questions, feel free to ping on this thread.

Thanks all! S