ajv icon indicating copy to clipboard operation
ajv copied to clipboard

SomeJSONSchema does not accept correct JSONSchemaType object

Open m-radzikowski opened this issue 3 years ago • 11 comments

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

Latest: 8.11.0

Ajv options object

not applicable - errors with TS types

Your code

interface Body {
    data: Record<string, unknown>;
}

const bodySchema: JSONSchemaType<Body> = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        data: {
            type: 'object',
        },
    },
    required: ['data'],
};

export const eventSchema: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        body: bodySchema,
    },
    required: ['body'],
};

TS error:

TS2322: Type '{ $schema: string; type: "object"; properties: { body: { type: "object"; additionalProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; unevaluatedProperties?: boolean | ... 1 more ... | undefined; ... 7 more ...; maxProperties?: number | undefined; } & { ...; } & { ...; } & { ...; }; }; requi...' is not assignable to type 'SomeJSONSchema'. 
  Types of property 'properties' are incompatible. 
    Type '{ body: { type: "object"; additionalProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; unevaluatedProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; ... 7 more ...; maxProperties?: number | undefined; } & { ...; } & { ...; } & { ...; }; }' is not assignable to type 'Partial<UncheckedPropertiesSchema<{ [key: string]: Known; }>>'. 
      Property 'body' is incompatible with index signature. 
        Type '{ type: "object"; additionalProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; unevaluatedProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; ... 7 more ...; maxProperties?: number | undefined; } & { ...; } & { ...; } & { ...; }' is not assignable to type '{ $ref: string; } | (UncheckedJSONSchemaType<Known, false> & { const?: Known | undefined; enum?: readonly Known[] | undefined; default?: Known | undefined; }) | undefined'. 
          Type '{ type: "object"; additionalProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; unevaluatedProperties?: boolean | UncheckedJSONSchemaType<unknown, false> | undefined; ... 7 more ...; maxProperties?: number | undefined; } & { ...; } & { ...; } & { ...; }' is not assignable to type '{ $ref: string; }'. 
            Types of property '$ref' are incompatible. 
              Type 'string | undefined' is not assignable to type 'string'. 
                Type 'undefined' is not assignable to type 'string'.

What results did you expect?

SomeJSONSchema should be possible to be composed of other valid schema objects.

Are you going to resolve the issue?

It can be over my possibilities.

m-radzikowski avatar May 19 '22 15:05 m-radzikowski

This is not a bug. Specific schema type does not extend SomeSchema type, they are constructed completely differently. SomeSchema type would only accept a valid schema object, but not the one that was typed as specific schema

epoberezkin avatar May 20 '22 06:05 epoberezkin

Thanks for the answer. But I'm not sure if I understand you fully.

SomeSchema type would only accept a valid schema object, but not the one that was typed as specific schema

Shouldn't SomeJSONSchema type accept any valid schema object? And JSONSchemaType<T> is a valid schema object.

I also found that SomeJSONSchema requires the required property, which AFAIK is not mandatory in JSON schema:

const schema: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
};
TS2322: Type '{ $schema: string; type: "object"; }' is not assignable to type 'SomeJSONSchema'.
   Type '{ $schema: string; type: "object"; }' is not assignable to type '{ type: "object" | undefined; additionalProperties?: boolean | UncheckedJSONSchemaType<Known, false> | undefined; unevaluatedProperties?: boolean | ... 1 more ... | undefined; ... 7 more ...; maxProperties?: number | undefined; } & { ...; } & { ...; } & { ...; }'.
     Property 'required' is missing in type '{ $schema: string; type: "object"; }' but required in type '{ required: readonly (string | number)[]; }'.

(this may be a separate, but related issue?)

m-radzikowski avatar May 23 '22 09:05 m-radzikowski

This is not that simple I am afraid. For typescript, these are three different types:

type 1:

const schema1 = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
}

type 2:

const schema2: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
}

type 3:

const schema3: JSONSchemaType<Record<any, any>> = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
}

You can see what type schema has in each of this cases by using typeof operator in typescript.

The way it works is that (typeof schema2) extends (typeof schema1) and (typeof schema3) extends (typeof schema1), but from it doesn't follow that (typeof schema3) extends (typeof schema2), as you want, schema2 and schema3 have types neither of which extends another, they are just not compatible from typescript point of view.

Just in case, typescript "extends" is a synonym for "narrows", it's always been quite confusing to me. It would have been nice if it were possible to make JSONSchemaType<T> extend SomeJSONSchema for any T, but I don't think it's possible using typescript type machinery, and, at the very least, it is not the case now.

cc @erikbrinkman – am I explaining it correctly?

epoberezkin avatar May 23 '22 10:05 epoberezkin

I also found that SomeJSONSchema requires the required property, which AFAIK is not mandatory in JSON schema:

This types are somewhat opinionated in what properties are required, with the purpose of avoiding mistakes. The absolute majority of object schemas have some properties that are required, and in most cases the absence of required indicates a programmer's mistake. To satisfy the type you can always add required: [], to signal that you didn't just forget to add required properties.

If all you want is the schema to be a valid JSON schema you can instead use validation, as part of app bootstrapping or test suite, and don't use this type at all...

epoberezkin avatar May 23 '22 10:05 epoberezkin

@epoberezkin in general what you're saying is true. Typescript isn't sound, and instead works by assignability, ignoring extra parameters. So by extending a type, you're just narrowing the the things it can apply to.

in general, doing something like const schema2: SomeJSONSchema only changes assignability when it comes to widening primitives.

const schema1 = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
}

get's treated as { $schema: string; type: string } when you wanted { $schema: string; type: "object" }.

However @m-radzikowski, the problem you're reporting seems sound.

If you add the required parameter, and switch it to be more general, you still get the same error:

const bodySchema: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        data: {
            type: 'object',
            required: []
        },
    },
    required: ['data'],
};
Types of property '$ref' are incompatible.
              Type 'string | undefined' is not assignable to type 'string'.
                Type 'undefined' is not assignable to type 'string'

The problem is that SomeJSONSchema is expecting the properties object to have a $ref field, and I'm not entirely sure why at first glance. If you cast it with as unknown as SomeJSONSchema & {$ref: string} to make TS think it has a ref, that works. Similarly, your original version (e.g. JSONSchemaType<Body> & { $ref: string }) also works. So this is less an issue of compatibility between schemas and more a problem of SomeJSONSchema expecting $ref for reasons unknown.

erikbrinkman avatar May 23 '22 16:05 erikbrinkman

Hm, I think the problem is elsewhere - $ref is an optional property in that type…

epoberezkin avatar May 23 '22 21:05 epoberezkin

SomeJSONSchema doesn’t use StrictNullChecksWrapper - @m-radzikowski , do you have strict null checks enabled?

epoberezkin avatar May 23 '22 21:05 epoberezkin

That's why I said I don't understand why, but it's not due to strict null checks. You can see the error in this playground.

erikbrinkman avatar May 24 '22 01:05 erikbrinkman

Thank you all for your answers, sorry for responding late.

This types are somewhat opinionated in what properties are required, with the purpose of avoiding mistakes. The absolute majority of object schemas have some properties that are required, and in most cases the absence of required indicates a programmer's mistake. To satisfy the type you can always add required: [], to signal that you didn't just forget to add required properties.

@epoberezkin Makes sense 👍

SomeJSONSchema doesn’t use StrictNullChecksWrapper - @m-radzikowski , do you have strict null checks enabled?

@epoberezkin yes, in tsconfig.json I have strictNullChecks: true. Changing it to false results in errors:

'"strictNullChecks must be true in tsconfig to use JSONSchemaType"'.

The problem is that SomeJSONSchema is expecting the properties object to have a $ref field, and I'm not entirely sure why at first glance. If you cast it with as unknown as SomeJSONSchema & {$ref: string} to make TS think it has a ref, that works. Similarly, your original version (e.g. JSONSchemaType<Body> & { $ref: string }) also works. So this is less an issue of compatibility between schemas and more a problem of SomeJSONSchema expecting $ref for reasons unknown.

@erikbrinkman yes, that seems to be true. I did a test with different variations of the schema:

const aSchema: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'string',
};

interface B {
    data: string;
}

const bSchema: JSONSchemaType<B> = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        data: {
            type: 'string',
        },
    },
    required: ['data'],
};

interface C {
    data: Record<string, unknown>;
}

const cSchema: JSONSchemaType<C> = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        data: {
            type: 'object',
        },
    },
    required: ['data'],
};

const parent: SomeJSONSchema = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    type: 'object',
    properties: {
        a: aSchema as unknown as SomeJSONSchema & { $ref: string },
        b: bSchema, // casting not needed
        c: cSchema as unknown as SomeJSONSchema & { $ref: string }, // casting to JSONSchemaType<C> & { $ref: string } also works
    },
    required: [],
};

As you can see, the $ref is required when nesting a SomeJSONSchema and JSONSchemaType that contains an object. But is not needed when SomeJSONSchema contains only a simple string as the type.

m-radzikowski avatar May 31 '22 11:05 m-radzikowski

Looking at this more closely, I don't think the $ref is the problem. I think that was a mistake because if it's { $ref: ... } then it passes any schema. However, that leaves me stumped.

If you remove the JSONSchemaType enforcement, then the assignment works. Somehow, the assignment to JSONSchemaType<Body> widens the type enough that it doesn't conform to SomeJSONSchema. Looking at the error, it seems to be passing as an array first in the cascade of type unions, and that is messing up the type.

However, that does suggest a stop gap to what's causing your error. You can assign sub schemas to their most narrow type with as const and then also verify that conforms to a sub schema, a la:

const bodySchema = {
  '$schema': 'https://json-schema.org/draft/2020-12/schema',
  type: 'object',
  properties: {
    data: {
      type: 'object',
      required: []
    },
  },
  required: ['data'],
} as const;

const _: JSONSchemaType<Body> = bodySchema;

export const eventSchema: SomeJSONSchema = {
  '$schema': 'https://json-schema.org/draft/2020-12/schema',
  type: 'object',
  properties: {
    body: bodySchema,
  },
  required: ['body'],
};

This scheme will do all the type checking you want, but it's a little verbose.

Unfortunately this error is also present in the v9 branch, but unfortunately I'm not sure how best to resolve it.

erikbrinkman avatar Jun 01 '22 02:06 erikbrinkman

Hey! I have same problems with nested object type, anyone found solution?

Found solution for me, maybe it will work in your case @m-radzikowski:

const schemaType: JSONSchemaType<SomeInterface> = { "definitions": {
    common: {
      type: 'object',
      properties: {
        key1: { type: 'string', minLength: 1},
        key2: { type: 'string', minLength: 1},
        data: { type: 'object', additionalProperties: { type: 'string' }, required: [] }
      },
      required: ['key1']
    }
  },
"oneOf": [...]
}

I have added required: [] to the nested type.

wjureczka avatar Oct 31 '23 21:10 wjureczka