TypeScript e WebSockets tipados

1. Fundamentos: O Desafio de Tipagem em WebSockets

1.1. A natureza dinâmica do WebSocket API nativo

A API nativa de WebSocket no navegador e no Node.js é essencialmente não tipada. O método send aceita string | ArrayBuffer | Blob | ArrayBufferView, enquanto onmessage recebe um MessageEvent cujo dado pode ser qualquer coisa. Isso cria um abismo entre o que o desenvolvedor espera receber e o que realmente chega.

// API nativa - sem segurança de tipos
const ws = new WebSocket("ws://localhost:8080");
ws.onmessage = (event) => {
  // event.data é 'any' - perigo iminente
  const data = JSON.parse(event.data);
  // Nenhum autocomplete, nenhuma verificação em tempo de compilação
  console.log(data.room); // Pode ser undefined em runtime
};

1.2. Problemas comuns

Os problemas mais frequentes incluem: ausência de contratos entre cliente e servidor, erros que só aparecem em produção, perda de autocomplete no editor, e dificuldade para refatorar o protocolo de mensagens. Em aplicações complexas, esses problemas se multiplicam exponencialmente.

1.3. Abordagem: tipagem estática do protocolo

A solução é tratar o WebSocket como um canal tipado, definindo contratos claros entre as partes. TypeScript nos permite fazer isso sem custos de runtime significativos.

2. Definindo o Protocolo com Tipos Discriminados

2.1. União de tipos para mensagens do cliente

type ClientMessage = 
  | { type: "join"; room: string }
  | { type: "send"; text: string }
  | { type: "leave"; room: string }
  | { type: "ping"; timestamp: number };

2.2. Mapeando eventos do servidor

type ServerMessage = 
  | { type: "user_joined"; username: string; room: string }
  | { type: "message"; username: string; text: string; timestamp: number }
  | { type: "error"; code: number; message: string }
  | { type: "pong"; timestamp: number };

2.3. Exaustividade com never

function handleServerMessage(msg: ServerMessage): void {
  switch (msg.type) {
    case "user_joined":
      console.log(`${msg.username} entrou em ${msg.room}`);
      break;
    case "message":
      console.log(`${msg.username}: ${msg.text}`);
      break;
    case "error":
      console.error(`Erro ${msg.code}: ${msg.message}`);
      break;
    case "pong":
      console.log(`Latência: ${Date.now() - msg.timestamp}ms`);
      break;
    default:
      // Se adicionarmos um novo tipo sem tratá-lo, o TypeScript aponta erro
      const _exhaustive: never = msg;
      throw new Error(`Tipo não tratado: ${_exhaustive}`);
  }
}

3. Wrapper Genérico para Conexão Tipada

3.1. Classe TypedWebSocket

class TypedWebSocket<TIn, TOut> {
  private ws: WebSocket;
  private handlers: Map<string, (msg: TOut) => void> = new Map();

  constructor(url: string) {
    this.ws = new WebSocket(url);
    this.ws.onmessage = (event) => {
      try {
        const msg = JSON.parse(event.data) as TOut;
        const handler = this.handlers.get((msg as any).type);
        if (handler) handler(msg);
      } catch (e) {
        console.error("Falha ao processar mensagem:", e);
      }
    };
  }

  send(msg: TIn): void {
    this.ws.send(JSON.stringify(msg));
  }

  on<K extends TOut extends { type: infer T } ? T : never>(
    type: K,
    handler: (msg: Extract<TOut, { type: K }>) => void
  ): void {
    this.handlers.set(type as string, handler as (msg: TOut) => void);
  }
}

3.2. Uso prático

type ChatClient = TypedWebSocket<ClientMessage, ServerMessage>;

const chat = new ChatClient("ws://chat.example.com");

chat.on("message", (msg) => {
  // msg é automaticamente tipado como { type: "message"; username: string; text: string; timestamp: number }
  console.log(`${msg.username}: ${msg.text}`);
});

chat.send({ type: "join", room: "typescript" });

3.3. Tratamento de ciclo de vida

class TypedWebSocket<TIn, TOut> {
  onOpen(handler: () => void): void { this.ws.onopen = handler; }
  onClose(handler: (code: number, reason: string) => void): void {
    this.ws.onclose = (e) => handler(e.code, e.reason);
  }
  onError(handler: (error: Event) => void): void { this.ws.onerror = handler; }
}

4. Serialização e Validação em Tempo de Execução

4.1. Integração com Zod

import { z } from "zod";

const ClientMessageSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("join"), room: z.string().min(1) }),
  z.object({ type: z.literal("send"), text: z.string().max(1000) }),
  z.object({ type: z.literal("leave"), room: z.string() }),
  z.object({ type: z.literal("ping"), timestamp: z.number() }),
]);

type ClientMessage = z.infer<typeof ClientMessageSchema>;

4.2. Função helper para parsing seguro

function parseMessage<T>(schema: z.ZodType<T>, data: string): { success: true; data: T } | { success: false; error: z.ZodError } {
  try {
    const parsed = JSON.parse(data);
    const result = schema.safeParse(parsed);
    return result.success 
      ? { success: true, data: result.data }
      : { success: false, error: result.error };
  } catch {
    return { success: false, error: new z.ZodError([{ 
      code: "custom", 
      message: "JSON inválido", 
      path: [] 
    }]) };
  }
}

4.3. Tipagem de erros

type ParseError = {
  type: "parse_error";
  errors: z.ZodIssue[];
  rawData: string;
};

type SafeServerMessage = ServerMessage | ParseError;

5. Tipagem Avançada: Template Literals e Mapped Types

5.1. Template literal types para eventos dinâmicos

type EventType = `event:${string}`;
type EventPayload<T extends EventType> = T extends `event:${infer Name}` 
  ? { name: Name; data: unknown }
  : never;

5.2. Mapped types para handlers automáticos

type EventSchema = {
  "user:login": { userId: string; token: string };
  "user:logout": { userId: string };
  "message:new": { content: string; author: string };
};

type Handlers = {
  [K in keyof EventSchema]: (payload: EventSchema[K]) => void;
};

class TypedEventBus {
  private handlers: Partial<Handlers> = {};

  on<K extends keyof EventSchema>(event: K, handler: Handlers[K]): void {
    this.handlers[event] = handler;
  }

  emit<K extends keyof EventSchema>(event: K, payload: EventSchema[K]): void {
    this.handlers[event]?.(payload);
  }
}

5.3. Key remapping para associação segura

type MessageMap = {
  join: { room: string };
  send: { text: string };
  leave: { room: string };
};

type TypedMessage = {
  [K in keyof MessageMap]: { type: K; payload: MessageMap[K] };
}[keyof MessageMap];
// Resultado: { type: "join"; payload: { room: string } } | { type: "send"; payload: { text: string } } | ...

6. Integração com Workers e Contextos Assíncronos

6.1. Tipando mensagens entre WebSocket e Worker

// worker.ts
type WorkerMessage = 
  | { type: "connect"; url: string }
  | { type: "disconnect" }
  | { type: "send"; data: ClientMessage };

type MainThreadMessage =
  | { type: "connected" }
  | { type: "disconnected"; code: number }
  | { type: "message"; data: ServerMessage };

self.onmessage = (event: MessageEvent<WorkerMessage>) => {
  // Typed handling
};

6.2. TypedWorker que comunica com WebSocket

class TypedWorker {
  private worker: Worker;

  constructor(scriptURL: string) {
    this.worker = new Worker(scriptURL);
  }

  postMessage(msg: WorkerMessage): void {
    this.worker.postMessage(msg);
  }

  onMessage(handler: (msg: MainThreadMessage) => void): void {
    this.worker.onmessage = (event) => handler(event.data);
  }
}

6.3. Transferable objects tipados

type TransferableMessage = 
  | { type: "binary"; data: ArrayBuffer }
  | { type: "stream"; data: Blob };

function sendTransferable(ws: TypedWebSocket<TransferableMessage, any>, msg: TransferableMessage): void {
  if (msg.type === "binary") {
    ws.send(msg); // ArrayBuffer
  } else {
    ws.send(msg); // Blob
  }
}

7. Padrões de Uso em Aplicações Reais

7.1. Exemplo completo: chat em tempo real

class ChatApplication {
  private ws = new TypedWebSocket<ClientMessage, ServerMessage>("ws://chat.example.com");
  private messages: ServerMessage[] = [];

  constructor() {
    this.ws.on("message", (msg) => {
      this.messages.push(msg);
      this.renderMessage(msg);
    });

    this.ws.on("user_joined", (msg) => {
      console.log(`${msg.username} entrou`);
    });

    this.ws.on("error", (msg) => {
      console.error(`Erro do servidor: ${msg.message}`);
    });
  }

  sendMessage(text: string): void {
    this.ws.send({ type: "send", text });
  }

  private renderMessage(msg: ServerMessage & { type: "message" }): void {
    // Renderização segura
  }
}

7.2. Sistema de plugins extensível

type PluginHandler<T extends ServerMessage["type"]> = {
  type: T;
  handler: (msg: Extract<ServerMessage, { type: T }>) => void;
};

class PluginSystem {
  private plugins: PluginHandler<any>[] = [];

  register<T extends ServerMessage["type"]>(plugin: PluginHandler<T>): void {
    this.plugins.push(plugin);
  }

  processMessage(msg: ServerMessage): void {
    this.plugins
      .filter(p => p.type === msg.type)
      .forEach(p => p.handler(msg));
  }
}

7.3. Testes unitários com mocks

import { vi, describe, it, expect } from "vitest";

function createMockWebSocket<TIn, TOut>(): TypedWebSocket<TIn, TOut> {
  const mock = {
    send: vi.fn(),
    on: vi.fn(),
    onOpen: vi.fn(),
    onClose: vi.fn(),
    onError: vi.fn(),
  };
  return mock as unknown as TypedWebSocket<TIn, TOut>;
}

describe("ChatApplication", () => {
  it("deve enviar mensagem ao servidor", () => {
    const mockWs = createMockWebSocket<ClientMessage, ServerMessage>();
    const chat = new ChatApplication(mockWs);
    chat.sendMessage("Olá");
    expect(mockWs.send).toHaveBeenCalledWith({ type: "send", text: "Olá" });
  });
});

8. Considerações Finais e Boas Práticas

8.1. Trade-offs

A tipagem estática em WebSockets adiciona complexidade inicial, mas reduz drasticamente bugs em produção. O custo de manutenção é compensado pela segurança e autocomplete.

8.2. Versionamento de protocolo

type ProtocolV1 = { version: "1.0"; message: ClientMessage };
type ProtocolV2 = { version: "2.0"; message: ClientMessageV2 };

type CurrentProtocol = ProtocolV2 extends { version: "2.0" } ? ProtocolV2 : ProtocolV1;

8.3. Dicas finais

  • Sempre valide mensagens recebidas em runtime (use Zod)
  • Mantenha os tipos do protocolo em um pacote compartilhado
  • Considere WebSocketStream (futuro) para streams tipados
  • Documente o protocolo com exemplos de uso

Referências