Skip to content

Server emitWithAck on typed Server has "never" typing #5257

Closed

Description

@mrjogo

Describe the bug

emitWithAck doesn't seem to be picking up types correctly, so the type variable Ev is assigned never.

To Reproduce

Socket.IO server version: 4.8.1

Server

import { Server } from "socket.io";

interface ServerToClientEvents {
  someEvent: (callback: (err: Error) => void) => void;
}
interface ClientToServerEvents {}

const io = new Server<ClientToServerEvents, ServerToClientEvents>();
io.on("connection", async (socket) => {
  // Typescript gives error "Argument of type 'string' is not assignable to parameter of type 'never'.ts(2345)"
  await socket.emitWithAck("someEvent");

  // No error
  socket.emit("someEvent", (err) => {});
});

Client

No client code necessary.

Expected behavior
No Typescript error (similar to emit behavior)

Platform:

  • Device: Macbook Air M2
  • OS: MacOS Sonoma 14.6.1

Additional context
Seems to be the same bug as mentioned in the comment #4925 (comment), but different from that issue.

Activity

  1. added
    to triageWaiting to be triaged by a member of the team
    on Dec 10, 2024
  2. barroudjo commented on Dec 14, 2024

    @barroudjo

    I have exactly the same problem

  3. andjsrk commented on Jan 22, 2025

    @andjsrk

    I think this is intentional, because:

    1. The parameters of callback have nothing other than Error.
    2. and socket.io explicitly excludes parameters of type Error:
      ? FirstNonErrorArg<Last<Parameters<Map[K]>>> extends void
      ? never
      : K
    3. There is no 'non-Error parameters' so FirstNonErrorArg returns never(or undefined? anyway this does not affect the conclusion) and never extends void is true.
    4. The compiler correctly complains there is no events available.
  4. mrjogo commented on Jan 22, 2025

    @mrjogo
    Author

    If it's intentional, it's unclear why you wouldn't be able to use a callback signature of (err: Error) => void (or () => void which also gives the same error). It's true that (num: number) => void does not cause the error.

  5. andjsrk commented on Jan 22, 2025

    @andjsrk

    Since both (err: Error) => void and () => void are unclear in perspective of "What does the event return?", I think excluding them is reasonable.
    If you intended "nothing to return", you could use (err: Error, res: void) => void, which is more explicit.

  6. mrjogo commented on Jan 22, 2025

    @mrjogo
    Author

    I don't see how they're unclear: callback: (err: Error) => void means "the event handler will call callback(err) with an Error object" (granted, I now realize it's a little bit of a weird case because it should probably be Error | undefined to optionally pass errors back, which the emitWithAck Typescript is fine with), and callback: () => void means "the event handler will call callback() to simply ack that it has received the event".

    For the latter, what's more indicative that it's a bug is that emit is perfectly happy with it, while emitWithAck is not. At the least, the behavior should be consistent.

  7. andjsrk commented on Jan 22, 2025

    @andjsrk

    emitWithAck (asynchronously) returns value that is specified on the callback, whereas emit does not.

    // example
    import { Server } from "socket.io"
    
    interface ServerToClientEvents {
      test: (cb: (result: number) => void) => void
    }
    const io = new Server<{}, ServerToClientEvents>()
    io.on("connection", socket => {
      const ackResult = socket.emitWithAck("test")
      // ackResult: Promise<number>
      const emitResult = socket.emit("test", () => {})
      // emitResult: boolean (irrelevant to the definition of events)
    })

    But, if there is no return type specified, what should the function return? Of course we could return undefined, but it is implicit. If the author intended to require explicit return type, this behavior is reasonable.

  8. mrjogo commented on Jan 22, 2025

    @mrjogo
    Author

    I understand what you're saying from the "how it's implemented" perspective, but I'm looking at it from the "what would a user expect" perspective. This is a perfectly reasonable thing to want to do and expect it will work:

    import { Server } from "socket.io"
    
    interface ServerToClientEvents {
      test: (cb: () => void) => void
    }
    const io = new Server<{}, ServerToClientEvents>()
    io.on("connection", async socket => {
      try {
        await socket.emitWithAck("test")
      catch (socketErr: any) {
        console.error(socketErr)
      }
    })

    Especially since it's doable in a non-async way:

    import { Server } from "socket.io"
    
    interface ServerToClientEvents {
      test: (cb: () => void) => void
    }
    const io = new Server<{}, ServerToClientEvents>()
    io.on("connection", socket => {
      await socket.emit("test", (socketErr) => {
        console.error(socketErr)
      })
    })
  9. andjsrk commented on Jan 22, 2025

    @andjsrk

    (This comment is not intended to defend the current behavior)
    FYI, even you write explicit void, you don't need to write additional code on call site:

    interface ServerToClientEvents {
      test: (cb: (result: void) => void) => void
    }
    
    // server side
    socket.emit("test", () => {})
    
    // client side
    socket.on("test", cb => {
      cb() // ok
    })
  10. null-d-e-v commented on Aug 23, 2025

    @null-d-e-v

    Hi there! I stumbled upon this issue while working with socket.broadcast.emitWithAck, and I thought my experience could help shed some light on the situation.

    In my case, I defined something like this on the server:

    export interface EmitEvents {
      [EmitEvent.SomeEvent]: (
        callback: (error: Error, value: { timestamp: string }) => void,
      ) => void;
    }

    This typing works fine on the server side, where you're expecting both an error and a value. But when I tried to reuse the same types on the client side, it became problematic: the client callback signature now requires both error and value, making it very awkward to call—even when all I care about is the value.

    To work around this, I created a small internal type helper that removes the Error parameter from callback types before applying them to the client:

    import { EmitEvent, ListenEvents } from "@repo/socket/events"; // Shared types
    import { io } from "socket.io-client";
    import type { Socket } from "socket.io-client";
    
    // Simple approach: we only focus on callbacks with Error as the first param
    type RemoveErrorFromCallback<T> = T extends (
      error: Error,
      ...args: infer Rest
    ) => infer Return
      ? (...args: Rest) => Return
      : T;
    
    // For functions that take a callback, we clean up that callback
    type CleanCallbackFunction<T> = T extends (
      callback: infer CB,
      ...args: infer Rest
    ) => infer Return
      ? (callback: RemoveErrorFromCallback<CB>, ...args: Rest) => Return
      : T;
    
    // Apply this to the entire interface
    type CleanErrorInEvents<E> = {
      [K in keyof E]: CleanCallbackFunction<E[K]>;
    };
    
    type ClientTypedSocket = Socket<
      CleanErrorInEvents<EmitEvents>,
      CleanErrorInEvents<ListenEvents>
    >;
    
    const socket: ClientTypedSocket = io("...");

    In short, this "cleans" the callback types for the client, so you don't end up with callback(error, value) enforced everywhere—you get just callback(value) instead.

    I know this is not a complete fix inside the Socket.IO type definitions, but maybe this approach could help others in the meantime.

  11. added a commit that references this issue on Feb 15, 2026
    5b41258
  12. darrachequesne commented on Sep 25, 2026

    @darrachequesne
    Member

    This should be fixed by 8b93a18, included in socket.io@4.8.4.

    Release notes: https://github.com/socketio/socket.io/releases/tag/socket.io%404.8.4

  13. added
    bugSomething isn't working
    and removed
    to triageWaiting to be triaged by a member of the team
    on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions