Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: recursive ToJSON #174

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

johannes-lindgren
Copy link

@johannes-lindgren johannes-lindgren commented Jul 20, 2024

This pull request addresses two issues with the ToJSON utility type:

  1. Recursively transforms the types of nested schemas. This is a major problem when using this library with React, as the ToJSON utility type does not transform nested objects. This means that you cannot define and initialize your state with `useState<ToJSON>(() => ({ ... }))
  2. Declare the type of toJSON on ArraySchema, MapSchema, and CollectionSchema. The return type was implicitly any. Fixes TypeScript: toJSON() return type on MapSchema / ArraySchema  #162

Regarding the first point, there were two major problems:

If you call .toJson() on a schema that has a property that is not mapped, the property would not be transformed by ToJson

export class VecSchema extends Schema {
    @type('number') x: number
    @type('number') y: number
}

class RoomSchema extends Schema {
    @type(VecSchema) pos: VecSchema
}

Even if you call .toJson() on a schema that has a property that is mapped, if the values in the map has non-primitive schemas, those will not be mapped either.

export class VecSchema extends Schema {
    @type('number') x: number
    @type('number') y: number
}

class Player extends Schema {
    @type(VecSchema) pos: VecSchema
}

class RoomSchema extends Schema {
    @type({map: Player}) players: MapSchema<Player>
}

which meant that you would get two type error if you tried to do

useState<ToJSON<RoomSchema>>(() => ({
    // Type error
    pos: { x: 0, y: 0 }
    players: {
        abc: {
         // Type error because the type of pos is VecSchema, not ToJSON<VecSchema>
         pos: { x: 0, y: 0 }
      }
    }
}))

Implementation

ToJSON contained many conditionals (one for MapSchema, Map, ArraySchema). Rather having all these conditionals , this PR changes the return type of the toJSON functions on each class so that they themselves describe the type transformation that takes place. ToJSON gets changed so that it simply evaluates to the return type of the toJSON function.

Question: ToJSON had a conditional for Map, but this came after the MapSchema conditional. Since MapSchema implements Map, I think the Map conditional never gets evaluated, so I don't think the new implementation of ToJSON needs to take this into consideration. Is this right, @endel?

Tests

I added a few tests that tests the ToJSON utility type. Since they are testing a utility type, these tests are checked by the type checker.

For some reason which I don't understand, the type checking would not be performed on the tests if they were placed directly in the test directory. As a workaround, I created a test/src directory to which I moved these tests.

Copy link
Author

@johannes-lindgren johannes-lindgren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@endel, what do you think of this proposal? If you like it, would you rather include it in version 3?

What motivates me to open this PR is that I am creating a game with React, but the ToJSON from this library is insufficient. I was using my own version (and wrappers to toJSON()), and thought that others might be interested in a solution as well 🙂

There is a failing check from codeclimate which I don't know how to resolve.

@@ -906,7 +908,9 @@ export abstract class Schema {
: this[`_${field}`];
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this part: why is there an underscore?

src/Schema.ts Outdated
Comment on lines 897 to 899
toJSON (): NonFunctionProps<{
[field in keyof this]: ToJSON<this[field]>
}> {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this type is not entirely accurate, because there is a conditional that seems to omit null values. This hasn't been taken into account, but this isn't being taken into account in the current implementation anyways.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 41 tests some types, but these types are never checked unless this file is moved into a subdirectory to the test folder.

@johannes-lindgren johannes-lindgren marked this pull request as ready for review July 20, 2024 21:12
@endel
Copy link
Member

endel commented Jul 22, 2024

Thank you for the PR @johannes-lindgren, I'm going to review this soon!

@endel
Copy link
Member

endel commented Aug 16, 2024

Hi @johannes-lindgren, thanks again for the PR, sorry for taking long to review it... I'm just trying to test via npm test but I'm getting this:

 Exception during run: test/Definition.test.ts(83,18): error TS2615: Type of property 'arrayOfContainers' circularly references itself in mapped type '{ name: never; arrayOfContainers: "arrayOfContainers"; mapOfContainers: never; onChange: "onChange"; onRemove: "onRemove"; assign: "assign"; setDirty: "setDirty"; listen: "listen"; decode: "decode"; ... 5 more ...; discardAllChanges: "discardAllChanges"; }'.
test/Definition.test.ts(83,18): error TS2615: Type of property 'arrayOfContainers' circularly references itself in mapped type '{ name: string; arrayOfContainers: any; mapOfContainers: Record<string, any>; onChange: (callback: () => void) => () => void; onRemove: (callback: () => void) => () => void; assign: (props: any) => Container; ... 8 more ...; discardAllChanges: () => void; }'.

Are you getting this error too?


As a side note, this is what I'm currently using in some React projects to workaround the ToJSON<> return types:

const roomState = useState<ReturnType<MyRoomState["toJSON"]>>(undefined);

@johannes-lindgren
Copy link
Author

johannes-lindgren commented Aug 23, 2024

Hi @endel, sorry for the failing test. I could have sworn that I ran all the test locally, but apparently, I didn't 😉

I will have a look again; the TypeScript error appears in recursive types.

@johannes-lindgren
Copy link
Author

johannes-lindgren commented Aug 23, 2024

@endel, here's an update: 0591a6d

I fixed the issue with the recursive schemas, but now I am struggling with another type error in TypeScriptTypes.test.ts:

View TypeScript error message
TSError: ⨯ Unable to compile TypeScript:
test/src/TypeScriptTypes.test.ts(43,57): error TS2344: Type 'SchemaInterfaceImpl' does not satisfy the constraint 'SchemaInterface'.
  Types of property 'assign' are incompatible.
    Type '(props: { players?: MapSchema<string, string>; items?: ArraySchema<string>; } | { players: { [x: string]: string; }; items: string[]; }) => SchemaInterfaceImpl' is not assignable to type '(props: { players: Map<string, string>; items: string[]; } | { players?: Map<string, string>; items?: string[]; }) => SchemaInterface'.
      Types of parameters 'props' and 'props' are incompatible.
        Type '{ players: Map<string, string>; items: string[]; } | { players?: Map<string, string>; items?: string[]; }' is not assignable to type '{ players?: MapSchema<string, string>; items?: ArraySchema<string>; } | { players: { [x: string]: string; }; items: string[]; }'.
          Type '{ players: Map<string, string>; items: string[]; }' is not assignable to type '{ players?: MapSchema<string, string>; items?: ArraySchema<string>; } | { players: { [x: string]: string; }; items: string[]; }'.
            Type '{ players: Map<string, string>; items: string[]; }' is not assignable to type '{ players: { [x: string]: string; }; items: string[]; }'.
              Types of property 'players' are incompatible.
                Type 'Map<string, string>' is not assignable to type '{ [x: string]: string; }'.
                  Index signature for type 'string' is missing in type 'Map<string, string>'.


The error appears on the last line of this code:

// Defines a generic schema
            interface SchemaInterface extends Schema {
                players: Map<string, string>;
                items: string[];
            }

            // Implements the above interface
            // MapSchema is compatible with Map
            class SchemaInterfaceImpl extends Schema implements SchemaInterface {
                players: MapSchema<string>;
                items: ArraySchema<string>;
            }

            // Uses the schema interface
            abstract class AbstractRoom<T extends SchemaInterface> { }

            // Uses the schema implementation
            class AbstractRoomImpl extends AbstractRoom<SchemaInterfaceImpl> { }

The error stems from a mismatch of function parameter types in Schema.ts:

assign(
        props: { [prop in NonFunctionPropNames<this>]?: this[prop] } | ToJSON<this>,
    ) {
        Object.assign(this, props);
        return this;
    }

I am relatively new to @colyseus/schema, so I don't understand how a "json-ified" version of the schema can be safely assigned? For example, if my schema expects the property myprop to be of MapSchema<string>, how come I am allowed to assign a Map<string, string> to myprop? Could it be that the type error is pointing out an actual error in the source code?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TypeScript: toJSON() return type on MapSchema / ArraySchema
2 participants