Sécuriser la gestion des événements Socket.io avec Type-Fest

Dans les projets Sockeet.io, l'absence de typage strict des événements entraîne fréquemment des bugs en production : noms d'événements mal orthographiés, structures de données incohérentes entre client et serveur, ou encore perte d'autocomlpétion dans l'IDE. Type-Fest propose des utilitaires de type qui permettent d'éliminer ces problèmes à la compilation.

Problématique du typage dans Socket.io

Une implémentation non typée ressemble généralement à ceci :

const client = io();

client.on('user-connected', (payload) => {
  // payload est de type 'any' — aucune vérification
  console.log(payload.userName); // faute de frappe non détectée
});

client.emit('new-message', {
  content: 'Salut',
  createdAt: new Date() // le serveur attend un timestamp numérique
});

Cette approche induit trois risques majeurs : erreurs de frappe sur les noms d'événements, inadéquation entre les données envoyées et attendues, et dérive entre la documentation et le code.

Outils de type essentiels

TaggedUnion pour les contrats d'événements

TaggedUnion génère une union discriminée à partir d'un objet de correspondances nom-événement / type-de-données :

import type { TaggedUnion } from 'type-fest';

type EventContract = TaggedUnion<{
  'member-connected': { memberId: string; displayName: string };
  'chat-sent': { body: string; sentAt: number };
  'member-disconnected': string;
}>;

Cette construction lie chaque nom d'événement à son type de données associé de manière statique.

UnionToTuple pour l'extraction des noms

UnionToTuple transforme une union en tuple, utile pour contraindre les paramètres de fonction :

import type { UnionToTuple } from 'type-fest';

type KnownEvents = UnionToTuple<EventContract['type']>;
// ['member-connected', 'chat-sent', 'member-disconnected']

function assertValidEvent<E extends KnownEvents[number]>(evt: E): E {
  return evt;
}

assertValidEvent('member-connected'); // valide
assertValidEvent('unknown-evt');     // erreur de compilation

Extraction des types de données avec Extract

En combinant Extract et les utilitaires de Type-Fest, on obtient un type de gestionnaire entièrement sûr :

type SafeListener = <E extends EventContract['type']>(
  evt: E,
  cb: (payload: Extract<EventContract, { type: E }>['data']) => void
) => void;

const register: SafeListener = (evt, cb) => {
  client.on(evt, cb);
};

register('member-connected', (info) => {
  // info est inféré comme { memberId: string; displayName: string }
  console.log(info.memberId);
});

Mise en œuvre d'un client Socket.io typé

Étape 1 — Définir les contrats

// file: src/contracts/socket-contracts.ts
import type { TaggedUnion } from 'type-fest';

export type ServerToClient = TaggedUnion<{
  'member-joined': { uid: string; label: string };
  'incoming-message': { msgId: string; body: string; author: string };
}>;

export type ClientToServer = TaggedUnion<{
  'enter-room': { roomKey: string };
  'post-message': { body: string; roomKey: string };
}>;

Étape 2 — Créer le wrapper typé

// file: src/clients/safe-socket.ts
import { io, type Socket } from 'socket.io-client';
import type { UnionToTuple } from 'type-fest';
import type { ClientToServer, ServerToClient } from '../contracts/socket-contracts';

type OutgoingEvents = UnionToTuple<ClientToServer['type']>;

export function buildSafeSocket() {
  const conn = io() as Socket<ServerToClient, ClientToServer>;

  function safeEmit<E extends OutgoingEvents[number]>(
    evt: E,
    payload: Extract<ClientToServer, { type: E }>['data']
  ) {
    conn.emit(evt, payload);
  }

  return { ...conn, emit: safeEmit };
}

Étape 3 — Utilisation dans les composants

import { buildSafeSocket } from './clients/safe-socket';

const sock = buildSafeSocket();

sock.on('member-joined', (member) => {
  console.log(`${member.label} est connecté`);
});

sock.emit('post-message', {
  body: 'Bonjour',
  roomKey: 'room-42'
});

sock.emit('post-message', {
  body: 'Erreur'
  // roomKey manquant → erreur de compilation
});

Bénéfices concrets

Avec cette architecture, l'IDE propose l'autocomplétion des noms d'événements et valide les structures de données à la saisie. Lors d'un renommage ou d'un ajout de champ, le compilateur signale immédiatement tous les sites d'utilisation à mettre à jour.

// Ajout d'un champ optionnel
type ServerToClient = TaggedUnion<{
  'member-joined': {
    uid: string;
    label: string;
    avatarUrl?: string;
  };
}>;

Techniques avancées

Gestion des données partielles

import type { PartialDeep } from 'type-fest';

type MessagePayload = {
  msgId: string;
  body: string;
  author: { uid: string; label: string };
};

type PatchableMessage = PartialDeep<MessagePayload>;
// Toutes les propriétés imbriquées deviennent optionnelles

Typage des middlewares

import type { Parameters, ReturnType } from 'type-fest/source/internal/type';

type Hook<F extends (...a: any[]) => any> = (
  ...args: Parameters<F>
) => ReturnType<F> | Promise<ReturnType<F>>;

const loggingHook: Hook<typeof sock.emit> = (evt, payload) => {
  console.debug(`[emit] ${evt}`, payload);
  return sock.emit(evt, payload);
};

Cette métohdologie s'étend naturellement à tout système piloté par événements : WebSockets natifs, EventEmitter Node.js, ou architectures pub/sub.

Étiquettes: TypeScript Type-Fest Socket.io type safety TaggedUnion

Publié le 28 juillet à 01h28