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
- Documentação oficial do TypeScript - Discriminated Unions — Guia completo sobre tipos discriminados, essencial para modelar protocolos de WebSocket
- Zod - Validação de esquemas TypeScript — Biblioteca de validação em runtime que se integra perfeitamente com tipos TypeScript
- MDN WebSocket API — Documentação oficial da API nativa de WebSocket
- TypeScript Template Literal Types — Tutorial sobre template literal types para criar tipos dinâmicos
- ws - Biblioteca WebSocket para Node.js — Implementação Node.js de WebSocket com suporte a tipos
- Valibot - Validação leve para TypeScript — Alternativa mais leve ao Zod para validação de mensagens
- WebSocketStream API - WICG — Proposta futura para WebSocket baseado em streams, com melhor suporte a tipagem