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

Add strictly typed events #3822

Closed
wants to merge 9 commits into from

Conversation

MaximeKjaer
Copy link
Contributor

@MaximeKjaer MaximeKjaer commented Mar 1, 2021

The kind of change this PR does introduce

  • a bug fix
  • a new feature
  • an update to the documentation
  • a code change that improves performance
  • other

Current behavior

User events

EventEmitter functions, namely on and emit, accept a string as the event name, and any[] as the remaining parameters. This allows users to:

  • Register listeners that listen to wrong event names
  • Register listeners that don't match the emitted values

Reserved events

Because the event emitter functions aren't strictly typed, it also means that server.on("connection", (socket: Socket) => { ... }) needs an explicit cast of socket: Socket.

New behavior

User events

TypeScript users can define interfaces for the API between server and client by defining interfaces:

interface Messages {
  hello: string;
}

When creating the Server, users can optionally pass this interface as a type parameter:

const server = new Server<Messages>(httpServer);

With this in place, calls to Socket.emit and Socket.on are strictly typed to only allow emitting and listening to "hello" events:

server.on("connection", (socket) => {
  socket.on("hello", (message) => console.log(message)); // works
  socket.on("goodbye", (message) => console.log(message)); // doesn't type check
});

It's also possible to define different messages for each direction of the connection:

interface ClientToServerMessages {
  ping: void;
}

interface ServerToClientMessages {
  pong: void;
}

const server = new Server<ClientToServerMessages, ServerToClientMessages>(httpServer);
server.on("connection", (socket) => {
  socket.on("ping", () => socket.emit("pong")); // works
  socket.on("pong", () => socket.emit("ping")); // doesn't type check
});

Note that if no type parameter is passed to Server, then events are typed as before.

Reserved events

server.on("connection", socket => { ... }) now correctly infers the Socket type for socket. No cast needed! Other reserved events are now also correctly inferred. Strict typing of reserved events is also present when no type parameters are passed to Server.

This does make this a breaking change if users rely on the on callback arguments being of type any. This was the case in this project's tests, where socket being of typed any was used to access private members of Socket. The fix is to cast as any, which makes the implicit any type explicit.

Other information (e.g. related issues)

Fixes #3742

lib/index.ts Outdated
@@ -645,14 +698,28 @@ export class Server extends EventEmitter {
return this;
}

public on<
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I suppose we would also need to strongly type once, removeListener, etc. Perhaps it might make sense to introduce an abstract class StrictEventEmitter between EventEmitter and Server; this would just override all methods to do all the strict typing, and call super's implementations. What do you think of that approach?

Copy link
Member

Choose a reason for hiding this comment

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

How about adding once() too? I think it should cover most use cases. And we'll add StrictEventEmitter in the future, if some users request it. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ended up moving it all to StrictEventEmitter to avoid duplicating all the overrides of on and once. And I added once, which has the exact same signature.


describe("server", () => {
describe("no event map", () => {
describe("on", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wrote this test in the same style as Mocha unit tests (describe / it), but these are not actually run. Perhaps it could make sense to refactor to some other structure, which might be less confusing.

Copy link
Member

Choose a reason for hiding this comment

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

The current format looks fine in my opinion. Maybe with a comment at the top stating that this file is covered by tsd?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea! Done :)

@darrachequesne
Copy link
Member

@MaximeKjaer I see you've already rebased your PR against the latest changes, nice! 👍

I'll take a look at it as soon as possible, but it looks good at first sight 👌

lib/index.ts Outdated
@@ -645,14 +698,28 @@ export class Server extends EventEmitter {
return this;
}

public on<
Copy link
Member

Choose a reason for hiding this comment

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

How about adding once() too? I think it should cover most use cases. And we'll add StrictEventEmitter in the future, if some users request it. What do you think?

@@ -24,3 +24,4 @@ jobs:
- run: npm test
env:
CI: true
- run: npm run test:types
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about adding npm run test:types in the npm test command instead? (to prevent surprising CI failures while npm test runs fine locally)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perfect! I placed it under npm test. I split that command into two subcommands, just to make it a little more readable.


describe("server", () => {
describe("no event map", () => {
describe("on", () => {
Copy link
Member

Choose a reason for hiding this comment

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

The current format looks fine in my opinion. Maybe with a comment at the top stating that this file is covered by tsd?

lib/index.ts Outdated
@@ -156,8 +156,58 @@ interface ServerOptions extends EngineAttachOptions {
connectTimeout: number;
}

export class Server extends EventEmitter {
public readonly sockets: Namespace;
/**
Copy link
Member

Choose a reason for hiding this comment

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

How about moving those declarations in their own file? (completely cosmetic, it's your call 👼 )

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm all for it! With the addition of StrictEventEmitter I think it made a lot of sense to move it all to a separate file.

@@ -952,8 +955,7 @@ describe("socket.io", () => {
const sio = new Server(srv);
srv.listen(() => {
const clientSocket = client(srv, { reconnection: false });
clientSocket.on("connect", function init() {
clientSocket.removeListener("connect", init);
clientSocket.once("connect", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Improving the typing of client Sockets showed that clientSocket.removeListener does not exist in component-emitter, so this had to be fixed in order to get tests to compile.

darrachequesne pushed a commit that referenced this pull request Mar 9, 2021
Syntax:

```ts
interface ClientToServerEvents {
  "my-event": (a: number, b: string, c: number[]) => void;
}

interface ServerToClientEvents {
  hello: (message: string) => void;
}

const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer);

io.emit("hello", "world");

io.on("connection", (socket) => {
  socket.on("my-event", (a, b, c) => {
    // ...
  });

  socket.emit("hello", "again");
});
```

The events are not typed by default (inferred as any), so this change
is backward compatible.

Note: we could also have reused the method here ([1]) to add types to
the EventEmitter, instead of creating a StrictEventEmitter class.

Related: #3742

[1]: https://github.com/binier/tiny-typed-emitter
@darrachequesne
Copy link
Member

Merged as 0107510.

I made a few minor updates:

  • <ListenEvents, EmitEvents> instead of <UserEvents, UserEmitEvents>
  • BroadcastOperator<EmitEvents> instead of BroadcastOperator<ListenEvents, EmitEvents>
  • NamespaceReservedEventsMap instead of ServerReservedEventsMap
  • EventParams<...> = Parameters<Map[Ev]> (so the typings are consistent)

Hope that's OK for you 👼

Anyway, awesome work, thanks a lot ❤️

This was referenced Mar 11, 2021
@phiresky
Copy link

Hey @MaximeKjaer! I'm the author of typed-socket.io, which I wrote a few years ago and seems pretty similar to this functionality you've now integrated here. I've added a note to the readme that similar functionality is now supported in socket.io itself, but since I'm not a huge socket.io user anymore, I'm not sure what the exact comparison would be. My library is still used by a fair amount of people, so if you have any specific info you think would be useful to my readme, let me know.

dzad pushed a commit to dzad/socket.io that referenced this pull request May 29, 2023
Syntax:

```ts
interface ClientToServerEvents {
  "my-event": (a: number, b: string, c: number[]) => void;
}

interface ServerToClientEvents {
  hello: (message: string) => void;
}

const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer);

io.emit("hello", "world");

io.on("connection", (socket) => {
  socket.on("my-event", (a, b, c) => {
    // ...
  });

  socket.emit("hello", "again");
});
```

The events are not typed by default (inferred as any), so this change
is backward compatible.

Note: we could also have reused the method here ([1]) to add types to
the EventEmitter, instead of creating a StrictEventEmitter class.

Related: socketio#3742

[1]: https://github.com/binier/tiny-typed-emitter
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.

send functions should have generic types
3 participants