support recursive & mutually recursive types#54
support recursive & mutually recursive types#54SebastienGllmt wants to merge 5 commits intoxddq:mainfrom
Conversation
fb6655e to
d7e888d
Compare
|
Hi @SebastienGllmt, thanks for another PR : ] Could you please provide me with a sample schema and output that fails in the current setup? If possible, mention your use case/scenario when you stumbled over this for me to better understand this. Initial thoughts after skimming through the PR:
I would argue that it is a "breaking change" since people with older typebox versions will simply not be able to use the generated code anymore. Good chance I might be wrong here, feel free to correct me. However, bumping the "feature" should be fine since we did not have any peer dependency set before anyway..? Thoughts?
Edit: please also take a look at ci issues (I think first will get solved with correct peer dependency and dev dependency version, second is just formatting missing) |
Yes, I added one as a test (recursive.json) which you can see here:
You need
makes sense. I've been using schema2typebox via npx, but I agree it makes sense to make the dependency setup work in cases where people want to add schema2typebox in their package.json. I'll look into the CI error in a bit |
dec6b17 to
71d4714
Compare
|
So this PR will make every exported typebox code (regardless if it contains recursive refs or not) into Module types, correct? What are the drawbacks of this? Will the performance using the generated code for validating be similar/same? Does this somehow use way more memory? Checking the docu it just mentions Module types as "Module types are containers for a set of referential types" src which to me sounds like this should be used for schemas containing references, but perhaos not for every typebox schema we generate..? Relying heavily on this could end up badly if there will be yet another replacement for Module syntax in typebox later on. If I understand this correctly, one drawback/change would be that if user try to generate the json schema for the generated typebox code they suddenly end up with a different schema containing #def blocks and #ref blocks instead of "just the schema". I don't know how people use this library, but that could be annoying. A few options I can think of: Honestly I am leaning towards a) or d). Given we really have no major drawbacks when using the module syntax everywhere I would be somewhat down to go with c) as well and merge this into 2.0 after reviewing. What are you thoughts on this? just a sample schema for me to better understand this when reading again, dummy schema {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "CommentWithReplies",
"title": "Comment",
"type": "object",
"properties": {
"author": {
"type": "string"
},
"message": {
"type": "string"
},
"replies": {
"type": "array",
"items": {
"$ref": "#"
}
}
},
"required": ["author", "message"]
}
|
Internally they are constructed as modules, but the exported type is the same as before (
It's useful even for cases with no references to be able to aggregate many json schema files into one module (see #55)
The code difference in this PR is not that big, so I don't think switching to an alternative would be a lot of work either
I tried to keep it the same by exporting However, it turns out (see later in this post) that this still has some performance impact, so we can unwrap it further with
Here are the result of benchmarks I did Validator
Initialization Assuming you have the following type Type.Object({
author: Type.String(),
message: Type.String(),
replies: Type.Array(Type.Object({}))
})Performance
Memory
Conclusion From the runtime performance, you can see that there is basically no change if we unwrap the import ourselves (so maybe it's better to add unwraping with You could argue that maybe we could generate different code to optimize the initialization time when there are no recursive references, but keep in mind
|
xddq
left a comment
There was a problem hiding this comment.
Looking good overall ✨ Main points:
- collect() feels a little too complicated
- examples have to be regenerated
- please only add recursive in this PR
| bundle | ||
| dist | ||
| node_modules | ||
| test/fixture |
There was a problem hiding this comment.
why was this added? Would prefer to have them formatted as well
| # 1.8.0 | ||
|
|
||
| - migrate to the Typebox [modules](https://github.com/sinclairzx81/typebox?tab=readme-ov-file#types-modules) to support recursive types [src](https://github.com/xddq/schema2typebox/pull/51) | ||
| - export any inner schema with an `$id` instead of only the top-level type [src](https://github.com/xddq/schema2typebox/pull/51) |
There was a problem hiding this comment.
Please drop the code specific to this feature in this PR. I want to focus on one feature at a time and the module one already takes quite some effort to review. In addition, I am not sure what the use case for this is/when this would be needed and if this should even be added. Open to discuss this later but for now, this should become a separate PR.
| - export any inner schema with an `$id` instead of only the top-level type [src](https://github.com/xddq/schema2typebox/pull/51) |
The code currently generates this
/**
* ATTENTION. This code was AUTO GENERATED by schema2typebox.
* While I don't know your use case, there is a high chance that direct changes
* to this file get lost. Consider making changes to the underlying JSON schema
* you use to generate this file instead. The default file is called
* "schema.json", perhaps have a look there! :]
*/
import { Static, Type } from "@sinclair/typebox";
export const Module = Type.Module({
Comment: Type.Object(
{
author: Type.String(),
message: Type.String(),
person: Type.Optional(Type.Ref("Person")),
replies: Type.Optional(Type.Array(Type.Ref("Comment"))),
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "CommentWithReplies",
}
),
Person: Type.Object(
{
name: Type.String({ maxLength: 100 }),
age: Type.Number({ minimum: 18 }),
},
{ $id: "Person" }
),
});
export const Comment = Module.Import("Comment");
export type Comment = Static<typeof Comment>;
export const Person = Module.Import("Person");
export type Person = Static<typeof Person>;based on these schemas
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "CommentWithReplies",
"title": "Comment",
"type": "object",
"properties": {
"author": {
"type": "string"
},
"message": {
"type": "string"
},
"person": {
"$ref": "./person.json"
},
"replies": {
"type": "array",
"items": {
"$ref": "#"
}
}
},
"required": [
"author",
"message"
]
}
{
"$id": "Person",
"title": "Person",
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 100
},
"age": {
"type": "number",
"minimum": 18
}
},
"required": ["name", "age"]
}
But the generated output should be
/**
* ATTENTION. This code was AUTO GENERATED by schema2typebox.
* While I don't know your use case, there is a high chance that direct changes
* to this file get lost. Consider making changes to the underlying JSON schema
* you use to generate this file instead. The default file is called
* "schema.json", perhaps have a look there! :]
*/
import { Static, Type } from "@sinclair/typebox";
export const Module = Type.Module({
Comment: Type.Object(
{
author: Type.String(),
message: Type.String(),
person: Type.Optional(Type.Ref("Person")),
replies: Type.Optional(Type.Array(Type.Ref("Comment"))),
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "CommentWithReplies",
}
),
Person: Type.Object(
{
name: Type.String({ maxLength: 100 }),
age: Type.Number({ minimum: 18 }),
},
{ $id: "Person" }
),
});
export const Comment = Module.Import("Comment");
export type Comment = Static<typeof Comment>;Otherwise it will be highly likely that one would have duplicated exports. E.g. "Person" 10 times because it was referenced in 10 different schemas that were the basis for code generation.
| "@jest/globals": "29.7.0", | ||
| "@sinclair/typebox": "0.30.2", | ||
| "@sinclair/typebox": "0.34.33", | ||
| "@tsconfig/node18": "2.0.0", |
There was a problem hiding this comment.
We need to set a peer dependency to @sinclair/typebox with version ^0.34 here, no? Otherwise people could use this programmatically with typebox 0.33 (or anything else) without a warning and without working code. The change should be somewhat similar to this.
| .join(",\n"); | ||
| const typeAliases = Array.from(entries.keys()) | ||
| .map((key) => { | ||
| return `export const ${key} = Module.Import('${key}'); |
There was a problem hiding this comment.
as mentioned above, please only export the first/top level schema (e.g. only Comment based on the example)
| schema: JSONSchema7Definition, | ||
| entries: ModuleEntries | ||
| ): Code => { | ||
| if (typeof schema === "object" && schema.$id !== undefined) { |
There was a problem hiding this comment.
The changes to collect() feel a little complicated/confusing. Can you come up with something simpler/easier to understand/maintain? The other changes look fine.
E.g. The part here in l.86 looks pretty similar to l.129. Also, the typeof schema === "object" could perhaps become part of parseObject()? Do we really need the iife here?
| @@ -0,0 +1,33 @@ | |||
| /** | |||
| } | ||
| }); | ||
|
|
||
| test("works for nested", async () => { |
There was a problem hiding this comment.
see above, please remove from this pr
| }, | ||
| "include": ["src", "test"], | ||
| "exclude": ["dist", "bundle", "node_modules"] | ||
| "exclude": ["dist", "bundle", "test/fixture", "node_modules"] |
There was a problem hiding this comment.
is there a reason for this? would prefer to not exclude this since it won't get type checked anymore
| @@ -37,39 +37,42 @@ const OneOf = <T extends TSchema[]>( | |||
| oneOf, | |||
There was a problem hiding this comment.
(not in this file, but there were no changes to examples)
We need to recreate/update all examples as well since they will now use Module.
| export const collect = ( | ||
| schema: JSONSchema7Definition, | ||
| entries: ModuleEntries | ||
| ): Code => { |
There was a problem hiding this comment.
QoL: Just a thought. It feels a little off that collect returns Code but in the end we don't use the generated code. We simply use the entries.
Maybe (might be bad idea, you will know better) we can keep it consistent and add some kind of placeholder whenever we would use the references to recursive types and then postprocess the Code from collect by replacing these placeholders based on the entries map..?
|
Thanks for the summary and the initial performance check. I think we should add this as you suggested to this PR
Regarding the rest, seems reasonable to use Module everywhere. If people ever have problems, this could be changed later anyway. |
|
First of all thank you @xddq for the base work and @SebastienGllmt for the PR. |
|
hey @bastiion
You have the open PR and my comments. If you want to invest the time to get this shipped, feel free to open up another PR with the comments adressed. I won't put time into shipping this myself, however I am down to review in a timely manner. If you come up with a PR in the next (1-10) days, you can expect a review within a couple (1-5) of days. It would be great if you could find an easier way to supporting recursive schemas (again, I might be totally wrong here) but it felt a little too complicated. If you put time into this and say this is the way to go, I am also fine. Would just be glad if this gets a closer look. |
|
@bastiion I won't have time to work on this, so you're free to yoink my code and modify it as required to get it merged |
|
FYI it looks like |
|
okay, then it was good to wait. At the moment I am working more with zod - but this thing gets interesting when working with types in Elysiajs Typebox I hope I will have time to look at it when 1.0.0 gets released and maybe this even opens up things like zod <--> typebox interoperability |
Previously, if you have any schema that was recursively defined (either simple recursion or mutually recursive), then the code would get stuck in an infinite loop until a
Maximum call stack size exceeded errorgot thrownSummary
To fix this, I used the (relatively new) Modules feature of Typebox. The benefits of using this feature are:
Type.Recursiveare somewhat deprecated and meant to move to the module system). Seetest/fixture/recursive.tstest/fixture/multi.ts