ajv: `undefined` treated as `null`able in JSONSchemaType

What version of Ajv are you using? Does the issue happen if you use the latest version?

7.0.2, yes

Your typescript code

const testSchema: JSONSchemaType<{ a?: string }> = {
    type: "object",
    properties: { a: { type: "string" } },
    required: [],
};

produces the following error, but

const testSchema: JSONSchemaType<{ a: string | null }> = {
  type: "object",
  properties: { a: { type: "string" } },
  required: ["a"],
};

type checks fine.

Typescript compiler error messages

error TS2322: Type '{ type: "string"; }' is not assignable to type '{ $ref: string; } | ({ type: "string"; minLength?: number | undefined; maxLength?: number | undefined; pattern?: string | undefined; format?: string | undefined; } & { [keyword: string]: any; ... 10 more ...; not?: Partial<...> | undefined; } & { ...; })'.
  Type '{ type: "string"; }' is not assignable to type '{ type: "string"; minLength?: number | undefined; maxLength?: number | undefined; pattern?: string | undefined; format?: string | undefined; } & { [keyword: string]: any; $id?: string | undefined; ... 9 more ...; not?: Partial<...> | undefined; } & { ...; }'.
    Property 'nullable' is missing in type '{ type: "string"; }' but required in type '{ nullable: true; const?: undefined; enum?: (string | null | undefined)[] | undefined; default?: string | null | undefined; }'.

     properties: { a: { type: "string" } },
                   ~

  node_modules/ajv/dist/types/json-schema.d.ts:95:5
    95     nullable: true;
           ~~~~~~~~
    'nullable' is declared here.

Describe the change that should be made to address the issue?

In essence the current type definition says that any non-required field should be nullable, but there is a distinction between null and undefined. I think the change should just be making this line null extends T instead of undefined extends T

I could also see another change that adds undefined to possible values in enum / default if undefined extends T, but maybe that’s not necessary since in general json does not allow for undefined values.

This change would also be breaking since the second example will now error as it used to compile (although the error seems correct).

Are you going to resolve the issue?

I can, but I’m seeking guidance on the fix first. Javascript distinguishes between undefined and null but json doesn’t, or more specifically undefined would imply an omitted field, where as null is a value. However, it’s not that simple since in some cases it seems that undefined values are omitted (in the case of object values) whereas in other cases they’re serialized as a null value (array elements). Therefore this change doesn’t seem super straightforward.

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 15
  • Comments: 17 (5 by maintainers)

Most upvoted comments

I feel like this is the breaking change that may thwart my type implementations. At an API level, sending an PUT request of {id: 123, a: 'hello'} is VERY different to {id: 123, a: 'hello', b: NULL}. The second is clearing the field. Unfortunately there is no was to consistently represent the first because AJV forces you to accept a NULL in the field.

EDIT: I see v9 has it, thanks. When is the release on NPM?

For anyone else like me who was confused:

If you want to define your Type like:

// ✅ 
type MyType {
  myProp: string | null;
}

And you want Ajv to correctly check it and allow a string or null like such:

const schema: JSONSchemaType<GetParams> = {
  type: 'object',
  properties: {
    myProp: {type: 'string', nullable: true}
  },
  required: ['myProp'], 
  additionalProperties: false
};

But don’t want JSONSchemaType complaining and forcing you to define your Type like this:

// 👎 
type MyType {
  myProp?: string | null;
}

// OR

// 👎 
type MyType {
  myProp?: string;
}

Then you need the use the inverse of @erikbrinkman proposed (though it was very helpful in pointing me in the right direction). You’ll want something like this:

export const nullable = <T>(input: T): T => {
  return {
    ...input,
    nullable: true
  } as T ;
}

And you’ll use it like this:

const schema: JSONSchemaType<GetParams> = {
  type: 'object',
  properties: {
    myProp: nullable({type: 'string'}), // <- Here is the change
  },
  required: ['myProp'], // Does not have to be required, but it will work as expected
  additionalProperties: false
};

With all that in place the following will be true:

const ajv = new Ajv();
const validate = ajv.compile(schema);

validate({}); // False, `myProp` is missing (assuming you set it as required)
validate({myProp: 123}) // False, wrong type
validate({myProp: null}) // True
validate({myProp: 'my string'}) // True

And your data (that you pass into validate) will be correctly typed as:

data.myProp // <- Type: string | null;

I hope this helps someone else. Personally I despise using undefined in place of null (they are very different) especially when passing data between client<->server so I was very glad to find a way to wrangle this library to handle all my validation while also using types the way that works best for me.

I’m running into issues with Ajv requiring "nullable": true for every non-required property as well. Considering copy-pasting https://github.com/ajv-validator/ajv/blob/v9/lib/types/json-schema.ts to my project for now as that seems to get around it. Thanks for the clue @JamesJansson.

now i make a copy of JSONSchemaType from Ajv , the new code in follow:

i fix two place, the RequiredMembers and the Nullable , now all are ok.

/* eslint-disable @typescript-eslint/no-empty-interface */
export type SomeJSONSchema = JSONSchemaType<Known, true>

export type PartialSchema<T> = Partial<JSONSchemaType<T, true>>

type JSONType<T extends string, _partial extends boolean> = _partial extends true
    ? T | undefined
    : T

export type JSONSchemaType<T, _partial extends boolean = false> = (T extends number
    ? {
        type: JSONType<'number' | 'integer', _partial>
        minimum?: number
        maximum?: number
        exclusiveMinimum?: number
        exclusiveMaximum?: number
        multipleOf?: number
        format?: string
    }
    : T extends string
        ? {
            type: JSONType<'string', _partial>
            minLength?: number
            maxLength?: number
            pattern?: string
            format?: string
        }
        : T extends boolean
            ? {
                type: 'boolean'
            }
            : T extends [any, ...any[]]
                ? {
                // JSON AnySchema for tuple
                type: JSONType<'array', _partial>
                items: {
                    [K in keyof T]-?: JSONSchemaType<T[K]> & Nullable<T[K]>
                } & { length: T['length'] }
                minItems: T['length']
            } & ({ maxItems: T['length'] } | { additionalItems: false })
                : T extends any[]
                    ? {
                        type: JSONType<'array', _partial>
                        items: JSONSchemaType<T[0]>
                        contains?: PartialSchema<T[0]>
                        minItems?: number
                        maxItems?: number
                        minContains?: number
                        maxContains?: number
                        uniqueItems?: true
                        additionalItems?: never
                    }
                    : T extends Record<string, any>
                        ? {
                            // JSON AnySchema for records and dictionaries
                            // "required" is not optional because it is often forgotten
                            // "properties" are optional for more concise dictionary schemas
                            // "patternProperties" and can be only used with interfaces that have string index
                            type: JSONType<'object', _partial>
                            // "required" type does not guarantee that all required properties are listed
                            // it only asserts that optional cannot be listed
                            required: _partial extends true ? (keyof T)[] : RequiredMembers<T>[]
                            additionalProperties?: boolean | JSONSchemaType<T[string]>
                            unevaluatedProperties?: boolean | JSONSchemaType<T[string]>
                            properties?: _partial extends true ? Partial<PropertiesSchema<T>> : PropertiesSchema<T>
                            patternProperties?: { [Pattern in string]?: JSONSchemaType<T[string]> }
                            propertyNames?: JSONSchemaType<string>
                            dependencies?: { [K in keyof T]?: (keyof T)[] | PartialSchema<T> }
                            dependentRequired?: { [K in keyof T]?: (keyof T)[] }
                            dependentSchemas?: { [K in keyof T]?: PartialSchema<T> }
                            minProperties?: number
                            maxProperties?: number
                        }
                        : T extends null
                            ? {
                                nullable: true
                            }
                            : never) & {
    [keyword: string]: any
    $id?: string
    $ref?: string
    $defs?: {
        [Key in string]?: JSONSchemaType<Known, true>
    }
    definitions?: {
        [Key in string]?: JSONSchemaType<Known, true>
    }
    allOf?: PartialSchema<T>[]
    anyOf?: PartialSchema<T>[]
    oneOf?: PartialSchema<T>[]
    if?: PartialSchema<T>
    then?: PartialSchema<T>
    else?: PartialSchema<T>
    not?: PartialSchema<T>
}

type Known = KnownRecord | [Known, ...Known[]] | Known[] | number | string | boolean | null

interface KnownRecord extends Record<string, Known> {
}

type PropertiesSchema<T> = {
    [K in keyof T]-?: (JSONSchemaType<T[K]> & Nullable<T[K]>) | { $ref: string }
}

type RequiredMembers<T> = {
    // [K in keyof T]-?: undefined extends T[K] ? never : K                  // <=== change this
    [K in keyof T]-?: undefined extends T[K] ? K : never                    // <=== FIX to this
}[keyof T]

type Nullable<T> = undefined extends T
    ? {
        // nullable: true                  // <=== change this
        nullable?: true                    // <=== FIX to this
        const?: never // any non-null value would fail `const: null`, `null` would fail any other value in const
        enum?: (T | null)[] // `null` must be explicitly included in "enum" for `null` to pass
        default?: T | null
    }
    : {
        const?: T
        enum?: T[]
        default?: T
    }


if any other have face same issue like me, you can try to make a copy file of JSONSchemaType.ts, fix that like above , import this one instead of the Ajv versioin.

I agree, it is quite an opinionated choice of how an optional field should be represented in JSON.

The reason for this choice is that it is quite common to allow null for optional fields in JSON and most typescript apps would not normally distinguish how null and undefined are processed…

This choice is partially made because you could use something like string | null in typescript but JSONSchemaType<T> doesn’t yet support unions so it would simply not work with it…

It is a relatively experimental new thing, but it’s usage is completely optional, as the doc says.

I am considering what’s the best way to evolve it - supporting unions would be a very valuable addition. Then we can possibly parameterise this choice via the second parameter of this type (and possibly change the current default to require nullable in v8)

Thank you - will close it then - let’s see what people ask for after all the changes to JSONSchemaType are released

@epoberezkin I think the change I suggested, adding an extra parameter to the type makes sense and should be viable.

However, I’m using jtd now which has more explicit treatment of the difference between undefined an null, so I don’t really care as much.

I’m happy to still put up a patch of you think it’s useful, but given that I’m the only one who’s complained, and I don’t care anymore, it seems like the added work and complexity aren’t justified.

i got the same error in all case.


export interface SysInfo {
    vLib: string;
    // ......
}


const SysInfoSchema: JSONSchemaType<SysInfo> = {
    type: 'object',
    properties: {
        vLib: {type: 'string', minLength: 1},
    },
    required: [
        'vLib',
    ],
    additionalProperties: false,
};

what ever the vLib in or not in the required list , the follow error also omit on webstorm.

TS2322: Type '{ type: "string"; minLength: number; }' is not assignable to type '{ $ref: string; } | ({ type: "string"; minLength?: number; maxLength?: number; pattern?: string; format?: string; } & { [keyword: string]: any; $id?: string; $ref?: string; $defs?: { [x: string]: JSONSchemaType<Known, true>; }; ... 7 more ...; not?: Partial<...>; } & { ...; })'.
   Type '{ type: "string"; minLength: number; }' is not assignable to type '{ type: "string"; minLength?: number; maxLength?: number; pattern?: string; format?: string; } & { [keyword: string]: any; $id?: string; $ref?: string; $defs?: { [x: string]: JSONSchemaType<Known, true>; }; ... 7 more ...; not?: Partial<...>; } & { ...; }'.
     Property 'nullable' is missing in type '{ type: "string"; minLength: number; }' but required in type '{ nullable: true; const?: never; enum?: string[]; default?: string; }'.  
json-schema.d.ts(95, 5): 'nullable' is declared here. 
SysInfo.ts(41, 5): The expected type comes from property 'vLib' which is declared here on type 'PropertiesSchema<SysInfo>'

2021-01-13 15_31_51-Window 2021-01-13 15_32_17-Window

and only can add nullable: true to avoid this error notice.

2021-01-13 15_33_35-Window

or use // @ts-ignore line by line.

2021-01-13 15_37_04-Window

so, now i cannot have a non-nullable field, if i dont want the error notice or the ts-ignore.

but in other siide, the code compile well, i think this is only a type check system error.


i seems that the code is follow :

declare type Nullable<T> = undefined extends T ? {
    nullable: true;                  //  <====== maybe this need be `nullable?: true;` ?
    const?: never;
    enum?: (T | null)[];
    default?: T | null;
} : {
    const?: T;
    enum?: T[];
    default?: T;
};

i’m using ajv 7.0.3 , and webstorm 2020.3.1/2020.3 .