ThomasAribart / json-schema-to-ts

Infer TS types from JSON schemas 📝
MIT License
1.4k stars 30 forks source link

Improve/simplify `DeepWriteable` and `FromSchema` #175

Closed TheDSCPL closed 7 months ago

TheDSCPL commented 7 months ago

This fixes a bug that makes this library completely unusable (Typescript 5.1.6).

I got the unknown type when I tried the library on my straightforward schema. So, I tried to copy and paste the examples into my projects, but they didn't work either. After playing around with the library's types in node_modules, I narrowed down the issue to the last ternary operator in the chain for the default value of RESULT in the ParseSchema type parameters, i.e., SCHEMA extends SingleTypeSchema. I tried changing it to SCHEMA extends {type: "object" }, which also didn't work, which was strange, as my object had to at least match that.

So I tried it directly in my source code:

import type { JSONSchema } from 'json-schema-to-ts'
import type { DeepWritable } from 'json-schema-to-ts/lib/types/type-utils/writable'

const dogSchema = {
  type: "object",
  properties: {
    name: { type: "string" },
    age: { type: "integer" },
    hobbies: { type: "array", items: { type: "string" } },
    favoriteFood: { enum: ["pizza", "taco", "fries"] },
  },
  required: ["name", "age"],
} as const satisfies JSONSchema;

// Dog is `unknown`
type Dog = FromSchema<typeof dogSchema>;

type DogSchemaWriteable = DeepWritable<typeof dogSchema>;

// IDE type tooltip says `Foo1` is `false`, but should be `true`
type Foo1 = DogSchemaWriteable extends { type: 'object' } ? true : false;

This led me to believe the Writeable type wasn't working well, or at least it was too complex that my IDE was bailing early from the type calculation. I noticed there was no short-circuit for simple primitive types, so I prepended the type with TYPE extends number | string | boolean | bigint | symbol | undefined | null | Function ? TYPE : and Foo1 was finally true in my previous example (when I hovered my mouse over the type variable).

So I tried the example again, but now Dog was never.

I then analysed the FromSchema type and copied it to my file to play around. I defined these types:

type Dog1 = ParseSchema<DogSchemaWriteable, ParseOptions<DogSchemaWriteable, FromSchemaDefaultOptions>>;
type Dog2 = M.$Resolve<Dog1>;

And Dog2 got the gigantic type definition:

{name: "name" extends keyof DeepMergeUnsafe<{}, {name: "name" extends keyof ObjectValues<Dog1> ? Resolve<ObjectValues<...>["name"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>, age: "age" extends keyof ObjectValues<Dog1> ? Resolve<ObjectValues<...>["age"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>}> ? ("name" extends keyof If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}> ? DeepMergeUnsafe<If<false, If<false, {[p: string]: ...<..., ...>}, {[p: string]: ...<..., ...>}>, {}>["name"], DeepMergeUnsafe<{}, {name: "name" extends keyof ...<...> ? Resolve<...[...], ...> : Resolve<Any, ...>, age: "age" extends keyof ...<...> ? Resolve<...[...], ...> : Resolve<Any, ...>}>["name"]> : DeepMergeUnsafe<{}, {name: "name" extends keyof ObjectValues<...> ? Resolve<...<...>["name"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>, age: "age" extends keyof ObjectValues<...> ? Resolve<...<...>["age"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>}>["name"]) : ("name" extends keyof If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}> ? If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}>["name"] : never), age: "age" extends keyof DeepMergeUnsafe<{}, {name: "name" extends keyof ObjectValues<Dog1> ? Resolve<ObjectValues<...>["name"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>, age: "age" extends keyof ObjectValues<Dog1> ? Resolve<ObjectValues<...>["age"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>}> ? ("age" extends keyof If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}> ? DeepMergeUnsafe<If<false, If<false, {[p: string]: ...<..., ...>}, {[p: string]: ...<..., ...>}>, {}>["age"], DeepMergeUnsafe<{}, {name: "name" extends keyof ...<...> ? Resolve<...[...], ...> : Resolve<Any, ...>, age: "age" extends keyof ...<...> ? Resolve<...[...], ...> : Resolve<Any, ...>}>["age"]> : DeepMergeUnsafe<{}, {name: "name" extends keyof ObjectValues<...> ? Resolve<...<...>["name"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>, age: "age" extends keyof ObjectValues<...> ? Resolve<...<...>["age"], ResolveDefaultOptions> : Resolve<Any, ResolveDefaultOptions>}>["age"]) : ("age" extends keyof If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}> ? If<false, If<false, {[p: string]: Resolve<...<...>, ...>}, {[p: string]: Resolve<Any, ...>}>, {}>["age"] : never)}

So I defined a more concise type to use as a replacement for FromSchema:

type FS<SCHEMA extends JSONSchema, OPTIONS extends FromSchemaOptions = FromSchemaDefaultOptions> = M.$Resolve<ParseSchema<DeepWritable<SCHEMA>, ParseOptions<DeepWritable<SCHEMA>, OPTIONS>>>

type Dog3 = FS<DogSchemaWriteable>;

and Dog3 now had the same big type as Dog2. Meaning, the WRITEABLE_SCHEMA was breaking the types and that changing it to a simpler implementation (now that DeepWritable can handle simple types we don't need that type variable with a default value) made it resolve again, even if it was to a huge type and impossible to debug without letting TS narrow it down more.

HOWEVER!

This, then, made me think that maybe my IDE was not doing a full type compilation when I hovered the mouse over to preview the type, so I went back to testing the original DeepWritable like this:

type FOO = DeepWritable<typeof dogSchema> extends { type: 'object' } ? true : false;
const foo: FOO = false;

My IDE said that type FOO was false, but there was a TS error on the assignment: TS2322: Type  false  is not assignable to type  true (!!!).

In sum:

Clearly, this is a bug with my IDE's simplistic type resolution, but the type is also clearly overly complicated where it doesn't need to be, so there's no harm in simplifying the types, especially in types that drill down so much, and I decided to make this PR anyway.

My guess is that

type DeepWritable<TYPE> = TYPE extends unknown[]
    ? TYPE extends [infer HEAD, ...infer TAIL]
      ? [DeepWritable<HEAD>, ...DeepWritable<TAIL>]
      : TYPE extends (infer VALUES)[]
      ? DeepWritable<VALUES>[]
      : never
    : {
        -readonly [KEY in keyof TYPE]: DeepWritable<TYPE[KEY]>;
      };

eventually narrows down to the primitive types because of the prototype properties, which, later down, get duck-typed back to the primitive. Making the DeepWritable type simpler and faster is a win, IMO, which is why I changed it in this PR!

Unless we want to allow primitive types with custom properties in JSON Schema, which are not even representable in JSON, there's no reason to break down primitives into their prototypes and then build them up again.

Note 1:

This is what I mean by primitives with custom properties:

type BAR = string & {a: number};
const bar: BAR = 'bar' as BAR;
bar.a = 1;

Such an exotic type can't even be represented in JSON, so covering that case at the cost of build time makes no sense.

Note 2:

Allowing a WRITABLE_SCHEMA to be a type parameter makes no sense because its only type restriction is extending WritableJSONSchema7, so it would allow it to be used like FromSchema<DogSchema, FromSchemaDefaultOptions, CatSchema>, and it would basically be the same as FromSchema<CatSchema, FromSchemaDefaultOptions> because the first type parameter is not actually used in the type's body, just in the 3rd type parameter's default value. So simplifying FromSchema also made sense to me!

Note 3:

The tests passed and I also ran prettier and the linter.

P.S.:

Great job on this library and ts-algebra! They are really cool!

ThomasAribart commented 7 months ago

Awesome ! Thank you very much @TheDSCPL ! Will have a look !

I'm not even sure DeepWritable is needed anymore. Maybe I can handle readonly types only as I think writable types extend them.

ThomasAribart commented 7 months ago

@TheDSCPL Should be fixed with https://github.com/ThomasAribart/json-schema-to-ts/pull/176