Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript: How to properly type socket events when creating a custom useSocket hook?

My socket server listens for a specific set of events, and so does my socket client. Here are my socket event definitions:

import { Server } from "socket.io";
import { Socket } from "socket.io-client";
import { DisconnectDescription } from "socket.io-client/build/esm/socket";

export interface ServerToClientEvents {
  connect: () => void;
  connect_error: (err: Error) => void;
  disconnect: (reason: Socket.DisconnectReason, description?: DisconnectDescription) => void;
  noArg: () => void;
  basicEmit: (a: number, b: string, c: Buffer) => void;
  withAck: (d: string, callback: (e: number) => void) => void;
}

export interface ClientToServerEvents {
  joinRoom: (name: string) => void;
}

interface InterServerEvents {
  ping: () => void;
}

interface SocketData {
  foo: string;
  bar: number;
}

export interface ServerIO extends Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData> {}

export interface SocketClient extends Socket<ServerToClientEvents, ClientToServerEvents> {}

I am trying to create a useSocket hook that ensures these events are properly typed. It returns functions called on and off, the former sets an event listener, the latter unsets it. For some reason, I cannot get the callback functions on both functions properly typed. Here is how I implemented it:

import { useEffect, useRef } from "react";
import io from "socket.io-client";
import { ClientToServerEvents, ServerToClientEvents, SocketClient } from "../@types/socketTypesNew";

type UseSocketOptions = { userID: string; endpoint?: string };

type UseSocketReturnType = {
  socket: React.MutableRefObject<SocketClient | null>;
  emit: <T extends keyof ClientToServerEvents>(eventName: T, data: Parameters<ClientToServerEvents[T]>) => void;
  on: <T extends keyof ServerToClientEvents>(
    eventName: T,
    callback: (...args: Parameters<ServerToClientEvents[T]>) => void
  ) => void;
  off: <T extends keyof ServerToClientEvents>(
    eventName: T,
    callback: (...args: Parameters<ServerToClientEvents[T]>) => void
  ) => void;
};

const useSocket = ({ userID, endpoint = "/api/socketio" }: UseSocketOptions): UseSocketReturnType => {
  const socket = useRef<SocketClient | null>(null);

  useEffect(() => {
    socket.current = io("/", {
      path: endpoint,
      port: 3000,
      closeOnBeforeunload: false,
    });
    socket.current.on("connect", () => {
      socket.current?.emit("joinRoom", userID);
    });
    socket.current.on("connect_error", console.log);

    return () => {
      socket.current?.disconnect();
    };
  }, [userID, endpoint]);

  const emit = <T extends keyof ClientToServerEvents>(
    eventName: T,
    data: Parameters<ClientToServerEvents[T]>
  ): void => {
    if (socket.current) {
      socket.current.emit(eventName, ...data);
    }
  };

  const on = <T extends keyof ServerToClientEvents>(
    eventName: T,
    callback: (...args: Parameters<ServerToClientEvents[T]>) => void
  ): void => {
    if (socket.current) {
      socket.current.on(eventName, callback); // ts error on callback
    }
  };

  const off = <T extends keyof ServerToClientEvents>(
    eventName: T,
    callback: (...args: Parameters<ServerToClientEvents[T]>) => void
  ): void => {
    if (socket.current) {
      socket.current.off(eventName, callback); // ts error on callback
    }
  };

  return {
    socket,
    emit,
    on,
    off,
  };
};

export default useSocket;

The error is Argument of type '(...args: Parameters<ServerToClientEvents[T]>) => void' is not assignable to parameter of type 'FallbackToUntypedListener<T extends "connect" | "connect_error" | "disconnect" ? SocketReservedEvents[T] : T extends "connect" | "connect_error" | "disconnect" | "noArg" | "basicEmit" | "withAck" ? ServerToClientEvents[T] : never>'.ts(2345)

Why does this happen? How do I fix it?

Dev environment

  • Typescript 4.7.3
  • ReactJS 18.2.0
  • ESLint 8.17.0
  • NextJS 13.2.1
  • socket.io/client 4.5.1
like image 329
Beki Avatar asked Sep 19 '25 08:09

Beki


2 Answers

I faced exactly the same issue today and here is a solution good enough for me:

We need to import some types:

import { DefaultEventsMap, EventNames, EventsMap } from "@socket.io/component-emitter";

Add a generic parameter to the SocketClient type to pass the server event map:

export interface SocketClient<SE> extends Socket<SE, ClientToServerEvents> {}

The same thing with UseSocketReturnType. Note using EventNames utility instead of keyof and full callback type coming from the event map:

type UseSocketReturnType<SE> = {
  socket: React.MutableRefObject<SocketClient<SE> | null>;
  emit: <T extends keyof ClientToServerEvents>(eventName: T, data: Parameters<ClientToServerEvents[T]>) => void;
  on: <T extends EventNames<SE>>(
    eventName: T,
    callback: SE[T]
  ) => void;
  off: <T extends EventNames<SE>>(
    eventName: T,
    callback: SE[T]
  ) => void;
};

Add appropriate changes to the hook

const useSocket = <SE extends EventsMap = DefaultEventsMap>({
  userID,
  endpoint = "/api/socketio",
}: UseSocketOptions): UseSocketReturnType<SE> => {
  const socket = useRef<SocketClient<SE> | null>(null);
  
  // useEffect ...

  // emit ...

  const on = <T extends EventNames<SE>>(
    eventName: T,
    callback: SE[T]
  ): void => {
    if (socket.current) {
      socket.current.on(eventName, callback); // OK
    }
  };

  const off = <T extends EventNames<SE>>(
    eventName: T,
    callback: SE[T]
  ): void => {
    if (socket.current) {
      socket.current.off(eventName, callback); // OK
    }
  };

  // return ...
};

Now you can use the hook with your events map:

const socket = useSocket<ServerToClientEvents>({ userID: 'foo' });

And you'll have proper autocomplete for on and off functions:

on(
  eventName:
    | "connect"
    | "connect_error"
    | "disconnect"
    | "noArg"
    | "basicEmit"
    | "withAck",
  callback:
    | (() => void)
    | ((err: Error) => void)
    | ((reason: Socket.DisconnectReason, description?: DisconnectDescription | undefined) => void)
    | (() => void)
    | ((a: number, b: string, c: Buffer) => void)
    | ((d: string, callback: (e: number) => void) => void)
): void

Here is a complete example: TS Playground

like image 143
Vladislav Avatar answered Sep 20 '25 23:09

Vladislav


Solved it by allowing any event names

interface ServerToClientEvents {
    // Allow any event names
    [k: string]: (val: any) => any;
}

interface ClientToServerEvents {
    // Allow any event names
    [k: string]: (val: any) => any;
}

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> =
    io(SOCKETS_URL);
export const SocketContext = React.createContext(socket);
like image 30
Alexander P Avatar answered Sep 20 '25 22:09

Alexander P