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 returning interface types from rpc methods #2040

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

alpertuna
Copy link

Fixes #2003

interface does not extend Serializable but type does.

interface R1 {
  field: string;
}
type R2 = {
  field: string;
}

R1 extends Serializable fails.
R2 extends Serializable ok.

After the patch both interface and type match Serializable.

This fix is a starting point for the issue, there may be a better solution.

Copy link

github-actions bot commented Apr 20, 2024

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

github-actions bot added a commit that referenced this pull request Apr 20, 2024
@alpertuna
Copy link
Author

Better fix may be done like this;

Here is Serializable type

type Serializable =
// Structured cloneables
| void
| undefined
| null
| boolean
| number
| bigint
| string
| TypedArray
| ArrayBuffer
| DataView
| Date
| Error
| RegExp
// Structured cloneable composites
| Map<Serializable, Serializable>
| Set<Serializable>
| ReadonlyArray<Serializable>
| { [key: string | number]: Serializable }
// Special types
| ReadableStream<Uint8Array>
| WritableStream<Uint8Array>
| Request
| Response
| Headers
| Stub<Stubable>
// Serialized as stubs, see `Stubify`
| Stubable;

This line does not match interfaces

    | { [key: string | number]: Serializable }

To make it work, Serializable should take the interface as a generic type like this;

type Serializable<T> =
...
    | { [K in keyof T]: Serializable<T[K]> }
...

But this solution make things more complicated.

@kentonv
Copy link
Member

kentonv commented Apr 22, 2024

The trouble is, we don't want user-defined classes to be considered serializable unless they extend RpcTarget.

Does this change keep that property, or will it make all user-defined classes be considered serializable?

@alpertuna
Copy link
Author

@kentonv I see the trouble, but the point is if developers believe that if the value is serializable and the type check fails, they will use some workarounds or will manuplate types, like here;

class SomeClass {
  member: number = 0;
}

type WorkAround = {
  member: number
};

class SomeDurableObject {
  async someRpc(): Promise<WorkAround> {
    return new SomeClass();
  }
}

// This will pass the check
const res = await someDoStub.someRpc();
console.log(res.member);

There is no way to avoid the example above.
So the real responsible part is the business logic that serializes.

I think that if the value is serializable, types should let it without workarounds that look unnecessary.

I also improved the fix and added a new constraint; object member cannot be a function.

Here is an example that shows the before and after the fix;

type RpcReturnType1 = {
  a: number;
  sub: {
    b: number;
  },
}
// Before fix: Ok
// After fix: Ok

interface SomeSubInterface {
  b: number;
}
interface RpcReturnType2 {
  a: number;
  sub: SomeSubInterface;
}
// Before fix: Fails
// After fix: Ok

type RpcReturnType3 = {
  cb: () => number
}
// Before fix: Ok (functions are considered objects, that's why they pass the check)
// After fix: Fails (added new constraint in the check)

Actually, it looks like returning callbacks are ok as I tested. (I didn't know rpc was capable of this)
As you said We don't want user-defined classes I just added the constraint, so developers use RpcTarget for advanced usage. But I'm not sure if this constraint should be there.

By the way, my work here is to speed up things, and it is ok if you don't like it as I understand your concerns : )

@alpertuna
Copy link
Author

I read the documentation about RpcTarget, and my fix conflicted with the documentation since using callback as rpc return type is valid usage.
I just reverted the function check in case of that you may use this pull request.

Now all the fix does is let class instances and interfaces as return types of an rpc method.

@kentonv
Copy link
Member

kentonv commented Apr 29, 2024

Can someone on the team who knows typescript better than me review this? @petebacondarwin @penalosa ?

@RamIdeas
Copy link
Contributor

LGTM

Just needed to use the generic within the recursive parts of the type too

@RamIdeas
Copy link
Contributor

RamIdeas commented Apr 30, 2024

The tests are failing because of the requirement Kenton metioned above: "we don't want user-defined classes to be considered serializable unless they extend RpcTarget"

I'll take another look at this now

Update:

I tried:

+ | (T extends RpcTargetBranded ?
        {
            [K in keyof T]: K extends number | string
              ? Serializable<T[K]>
              : never;
          }
+        : never)

But it didn't seem to change the tests outcome. Even if it worked, it would've prevented plain objects from being serializable.

It looks like there's no way in typescript to determine if an object is a class/subclass vs a plain object microsoft/TypeScript#29063

I think the way forward here is to allow classes to be serializable but make clear to users that the serialized form is a plain-object, not the original class instance

@alpertuna
Copy link
Author

I think the way forward here is to allow classes to be serializable but make clear to users that the serialized form is a plain-object, not the original class instance

Since the returned object is stubified, return types of all methods of the object will be Promise, and that will make a visible difference that it is not the original class instance. (Unless all methods of the original class are already returning Promise.)

@kentonv
Copy link
Member

kentonv commented Apr 30, 2024

Regardless of what the type system says, attempting to serialize an instance of a class (that does not extend RpcTarget) will fail at runtime. This is an intentional design choice which we won't be changing.

If there's no way for TypeScript to enforce the same at type-checking time, then it seems we should make TypeScript more lenient, and just live with the fact that this problem won't be diagnosed until runtime.

@alpertuna
Copy link
Author

alpertuna commented Apr 30, 2024

If class instances cause failure at runtime, I respect and agree that types should not let this usage.

Btw I missed fixing other Structured cloneable composites types, that's my fault and a proper fix should be like this;

type Serializable<T> =
...
  | Map<
      T extends Map<infer U, unknown> ? Serializable<U> : never,
      T extends Map<unknown, infer U> ? Serializable<U> : never
    >
  | Set<T extends Set<infer U> ? Serializable<U> : never>
  | ReadonlyArray<T extends ReadonlyArray<infer U> ? Serializable<U> : never>
...

Now my main concern is Serializable type is getting spagettier and may be harder to maintain future improvements, so I'm not confident with this fix.

@RamIdeas
Copy link
Contributor

RamIdeas commented May 7, 2024

Regardless of what the type system says, attempting to serialize an instance of a class (that does not extend RpcTarget) will fail at runtime. This is an intentional design choice which we won't be changing.

To clarify, I did mean to allow it in the typings, not to change the runtime behaviour to allow it.

I admit though I made an assumption that, since plain objects are allowed, classes would work in some form at runtime as if they were plain objects – eg. instance properties and methods work but prototype methods would not. But I see from the docs that we have decided that behaviour is "rarely useful in practice" and to avoid the footgun.

If there's no way for TypeScript to enforce the same at type-checking time, then it seems we should make TypeScript more lenient, and just live with the fact that this problem won't be diagnosed until runtime.

Essentially, what I was saying. And if we deem it useful to catch these anti-patterns at dev-time, we can implement a lint rule.

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.

rpc can only return type not interface/class
3 participants