Stack Builders logo
Arrow icon Insights

Strongly Typed Realtime Programming with TypeScript

Your sockets are not safe, and you should be worried about them

Real-time applications (RTA) have been getting a lot of attention in the past few years, and the underlying concepts can be used to make collaborative software in a streamlined way. Simple chats, gaming platforms, and even Google's document suite use real-time communications to improve user experience and collaboration. There is plenty of documentation online about implementing a socket server, broadcasting messages from it, and using callbacks to listen for those messages with JavaScript. However, most of these articles tend to forget that you can send virtually any object through a socket and that the receiver might be using that message incorrectly. In this article we'll explore how to make socket communication more secure and robust. But first let's review some concepts.

A 2 minute introduction to WebSockets

WebSockets are part of the native JavaScript API, and they enable two way communication between the user's browser and a server. However, the interesting part is that with this technology you can either send messages to a server, or receive them without manually polling the server for replies. It is based on event emitters and event listeners

Let's start with a simple example. Suppose we have a chat application where we implement a chat_message event, which triggers when a user sends a message. We'll be building our little example with socket.io, but you can use any of the socket libraries to build your own implementation. Anyways, our basic server in plain JavaScript would look something like this:

// server.js
const app = require("express")();
const http = require("http").createServer(app);
const io = require("socket.io")(http);

io.on("connection", (socket) => {
  socket.on("chat_message", (message) => {
    io.emit("chat_message", message);
  });
});

As you can see, the server is doing a simple thing: Listen for any chat messages from a client and resend it to all of the clients listening for the event. This means that the server can receive data of any form, and it will forward that to all clients listening to that particular event. Looking at a simple client implementation we would probably have something like this:

// client.js
const io = require("socket.io-client")();

const messageList = document.querySelector("#chatbox");
const messageInput = document.querySelector("#my-message");
const sendButton = document.querySelector("#send");

// Listening to a message
io.on("chat_message", (message) => {
  messageList.appendChild(`<p>${message}</p>`);
});

// Sending a message when clicking on the button
sendButton.addEventListener("click", () => {
  io.emit("chat_message", messageInput.value);
});

So in general we can say that a socket application is based on event emitters and event listeners:

  • Emitter: A function that broadcasts a specific event. In a chat application, this emitter would get executed when sending a new message to the server. This message will have an event identifier and a content.
  • Listener: A function that gets executed when a specific event has happened. Using our chat application as an example, a listener would subscribe to an event that will get triggered when getting a new message from a user. This message would then get processed accordingly, or forwarded to other listeners.

You can have other variations of this pattern, depending on the needs of your application. For example, you may need to emit the messages only to certain listeners, in which case you would have to keep track of the listener's unique identifier and send messages only to them. For the code in this article we will use a simple example, where we broadcast messages to all clients.

Great! But why do we need type safety?

This is a typical example of an interaction between the server and the client. In this case you wouldn't be able to send something different than a string as a socket message, since we're sending the string value of messageInput. However, right off the bat we can see two problems with this implementation.

  1. It is not maintainable: If I ever want to change the chat_message event name to something like someone_said_something I would have to change every single place in my code where that event is being called or listened on. In our current example we only have one file and just a few occurrences, but you can see how it could get out of control with multiple files.

  2. It is not safe: We're only sending strings to the socket in this example, however, nothing prevents me from doing something like:

io.emit("chat_message", {user: "Marty McFly", message: "Hey Doc!"})`

The issue is less apparent in the server implementation, where we only forward that message to the clients, but it really becomes a problem on the client listeners. In our case, receiving this message and printing it out on the chat container would output the infamous [object Object] string to our DOM.

So in today's article, we'll be exploring how to make sockets more maintainable and most important of all: Type safe

TypeScript to the rescue!

Since its inception, TypeScript has helped a lot of applications gain type safety with just simple changes. If you haven’t adopted it yet, go ahead and give it a try! It will prevent a lot of headaches for you.

With the sales pitch out of the way, let's pull @types/socket.io and see if the typings are safe. We'll check the typings for on and emit in the server code first:

on(event: string, listener: Function): Namespace;
emit(event: string, ...args: any[]): Namespace;

With on, we can send any string as an event, and any function as a listener. With emit the case is pretty similar, with an explicit any in the argument list. So this means we can virtually send any event, and listen to it however we want. This makes sense from a library standpoint, where we want to give flexibility to our users, but as a consumer we need to take some precautions here. Let's check the typings for @types/socket.io-client now:

on( event: string, fn: Function ):Emitter;
emit( event: string, ...args: any[] ):Emitter;

The case is practically the same. This means we will need to handle type safety from inside our app, but how can we do that in a concise and maintainable way?

Like a Christmas gift: Wrap the server!

It's a good practice to wrap external libraries to improve maintainability, since in this case if you want to switch to another socket implementation you would only have to change the library's operations in a single place. Let's give the server a first pass, with typings and a little bit of wrapping:

import { Server } from "http";
import socketIO, { Socket } from "socket.io";

let io = null;

export function createSocketServer(server: Server) {
  io = socketIO(server);
  io.on("connection", (socket: Socket) => {
    // Bind your listeners here
  });
}

Not much of a change, right? We just added a few types to keep things safe. The let io = null; line is not ideal since in a large scale application you'd want to treat the socket server as a singleton, but it'll work for our example.

Now let's think about this for a little bit. We want to have a central location for all of our sockets, and have a safer way to define them. I came up with something like this:

type SocketMessage = "chat_message";

type SocketActionFn<T> = (message: T) => void;

interface WrappedServerSocket<T> {
  event: string;
  callback: SocketActionFn<T>;
}

function broadcast<T>(event: SocketMessage) {
  return (message: T) => io.emit(event, message);
}

function createSocket<T>(
  event: SocketMessage,
  action?: SocketActionFn<T>
): WrappedServerSocket<T> {
  const callback = action || broadcast(event);
  return { event, callback };
}

Whoa, let's slow down and analyze what's happening here, from the bottom up:

We implemented a createSocket function, which will wrap our operations. The first argument is event, with a SocketMessage type. Right now can only take a chat_message value, but since it'll grow in the future it will prevent us from using events we haven't implemented yet.

The second argument for the function is action, which takes a SocketActionFn type. This will be the server action which we'll use to process the message. This will be useful if we not only want to broadcast the message but do something additional like persisting data or checking for a condition. If we don't send an action, the operation defaults to a simple broadcast.

Finally, we'll return an object with the event and the callback we'll be running. We'll use this to build a list of WrappedServerSocket objects, just so we can iterate over it and initialize all listeners in one go.

You can see we're using TypeScript's generics here, and that is what keeps the whole example type-safe. If we use createSocket with a string type, this means the action parameter will be a SocketActionFn<string>.

Finally, our server setup can look like this:

import { Server } from "http";
import socketIO, { Socket } from "socket.io";

let io = null;

export function createSocketServer(server: Server) {
  io = socketIO(server);
  io.on("connection", (socket: Socket) => {
    registeredEvents.forEach(({ event, callback }) => {
      socket.on(event, callback);
    });
  });
}

const chatMessageEvent = createSocket<string>("chat_message");

const registeredEvents = [chatMessageEvent];

And voilá! Our server is type-safe. Let's say we want to register a new event called user_connected, which receives a User object with this form:

interface User {
  id: string;
  name: string;
}

To allow that we would just add user_connected to the SocketMessage type and use createSocket to define the callback. Something like this:

type SocketMessage = "chat_message" | "user_connected";

// To simply broadcast the message we omit the second parameter
const userConnectedEvent = createSocket<User>("user_connected");

// If we want to do a custom action we can pass a function as the second parameter
const userConnectedLogEvent = createSocket<User>("user_connected", (user) => {
  console.log(user.id); // Compiles OK, user gets inferred as User
  console.log(user.type); // TypeError! Type doesn't exist in User
});

But that's only one side of the coin. Now let's check how to do it on the client's side:

Like Santa Claus would also do: Wrap the client!

The client implementation is pretty similar to the server. Let's take a look:

import { SocketMessage, User } from "../contracts/events";

import socketIOClient from "socket.io-client";

const socketClient = socketIOClient();

interface EmitterCallback<T> {
  (data: T): void;
}

interface WrappedClientSocket<T> {
  emit: (data: T) => SocketIOClient.Socket;
  on: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
  off: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
}

function createSocket<T>(event: SocketMessage): WrappedClientSocket<T> {
  return {
    emit: (data) => socketClient.emit(event, data),
    on: (callback) => socketClient.on(event, callback),
    off: (callback) => socketClient.off(event, callback),
  };
}

const chatMessageEvent: WrappedClientSocket<string> =
  createSocket("chat_message");
const userConnectedSocket: WrappedClientSocket<User> =
  createSocket("user_connected");

In a similar fashion to our server, we wrap the client socket operations in a WrappedClientSocket type. The createSocket function returns an object, where keys are the operations and values are generic functions. We can achieve this by keeping EmitterCallback generic and passing the T type down from the createSocket function.

We would use these sockets as such:

// None of the following will typecheck.
// 'on' and 'off' will not infer 'message' as a string
// We can still pass anything to the second argument of 'emit'
socketClient.on("chat_message", (message) => console.log(message));
socketClient.off("chat_message", (message) => console.log(message));
socketClient.emit("chat_message", "Hey Doc!");

// Instead, let's do:
// 'on' and 'off' will infer 'message' as a string
// We can only pass strings to 'emit'
chatMessageEvent.on((message) => console.log(message));
chatMessageEvent.off((message) => console.log(message));
chatMessageEvent.emit("Hey Doc!");

//This will fail: Argument of type number is not assignable to parameter of type string.
chatMessageEvent.emit(1);

As you can see our socket implementation is safe now! No more Uncaught TypeError: Cannot read property 'user' of undefined in your runtime code. Yay!

Closing remarks

From this article you can see how easy it is to add typings to unsafe operations by implementing static typing in your code. Adopting TypeScript into an existing codebase is a simple process that can be done incrementally and without too much implicit costs. You can start small, with simple compiler options and using just a few types for your more critical functions.

I would strongly advise keeping your types consistent between frontend and backend with contracts. This means in our case the SocketMessage type and the User interface should be shared between frontend and backend through a TypeScript module, or a project reference. If you want more details about it, our tech lead Fernanda Andrade gave a talk in TSConf 2019 about it (Don’t break the contract: Keep consistency with Full Stack Type Safety), check it out!

Using types to prevent runtime errors for undefined values means that you will write less defensive code that is cleaner, and what's more important, checked for errors in compilation time.

Happy typing!

Published on: Aug. 18, 2020
Last updated: Dec. 20, 2024

Written by:

Subscribe to our blog

Join our community and get the latest articles, tips, and insights delivered straight to your inbox. Don’t miss it – subscribe now and be part of the conversation!
We care about your data. Check out our Privacy Policy.