Introdução ao TypeScript

[118] Introdução ao TypeScript

Uma variável que é número agora e string depois dá flexibilidade e gera uma classe inteira de erro que só aparece em produção. O TypeScript move essa checagem para o editor: tipos primitivos, interfaces, type aliases, generics, os utility types prontos, classes com modificador de acesso e como tipar Express e Mongoose.
Javascript

28 min de leitura

JavaScript é uma linguagem dinamicamente tipada. Isso significa que uma variável pode ser um número agora, uma string depois, e undefined mais tarde — e o interpretador não reclama. Isso oferece flexibilidade, mas também é a fonte de uma classe inteira de bugs que só aparecem em produção, à meia-noite, no pior momento possível.

O TypeScript resolve isso adicionando um sistema de tipos ao JavaScript. Não é uma linguagem nova — é JavaScript com superpoderes que some em tempo de execução. Todo código TypeScript vira JavaScript. Todo código JavaScript válido é TypeScript válido.

Por que TypeScript?

// ── JavaScript ─────────────────────────────────────
function calcularDesconto(preco, percentual) {
  return preco - (preco * percentual / 100);
}

calcularDesconto(100, 10);       // 90 ✅
calcularDesconto("100", 10);     // 90 😱 — funciona, e esse é o problema
calcularDesconto("100,50", 10);  // NaN 😱 a vírgula decimal quebra tudo
calcularDesconto(100, "dez");    // NaN 😱
calcularDesconto();               // NaN 😱 sem argumento, sem aviso

// A segunda linha merece atenção: - e * CONVERTEM a string em número, então
// o resultado sai certo por acidente e o defeito fica escondido até a
// entrada vir num formato diferente. Quem concatena é o +, e aí sim:
function somarAoTotal(total, preco) { return total + preco; }
somarAoTotal(100, "10");         // "10010" 😱 aí sim, concatenação

// ── TypeScript ─────────────────────────────────────
function calcularDesconto(preco: number, percentual: number): number {
  return preco - (preco * percentual / 100);
}

calcularDesconto(100, 10);        // 90 ✅
calcularDesconto("100", 10);      // ❌ Erro em tempo de compilação
calcularDesconto(100, "dez");     // ❌ Erro em tempo de compilação
calcularDesconto();                // ❌ Erro em tempo de compilação

O erro aparece antes de rodar — no editor, enquanto você digita.

Instalando e configurando

# Instalar globalmente (para o compilador tsc)
npm install -g typescript

# Instalar no projeto
npm install -D typescript

# Inicializar configuração
npx tsc --init
// tsconfig.json — configuração essencial para Node.js
{
  "compilerOptions": {
    "target": "ES2022",           // versão do JavaScript gerado
    "module": "commonjs",          // sistema de módulos (Node)
    "lib": ["ES2022"],             // bibliotecas disponíveis
    "outDir": "./dist",            // onde o JS compilado vai
    "rootDir": "./src",            // onde está o TypeScript
    "strict": true,                // ativa todas as verificações rígidas
    "esModuleInterop": true,       // compatibilidade com CommonJS
    "skipLibCheck": true,          // ignora erros em node_modules
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,     // permite import de JSON
    "declaration": true,           // gera arquivos .d.ts
    "sourceMap": true,             // mapas para debugging
    "noUnusedLocals": true,        // erro para variáveis não usadas
    "noUnusedParameters": true,    // erro para parâmetros não usados
    "noImplicitReturns": true,     // toda função deve retornar
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// package.json — scripts para TypeScript
{
  "scripts": {
    "build": "tsc",
    "build:watch": "tsc --watch",
    "dev": "ts-node-dev src/index.ts",
    "start": "node dist/index.js"
  }
}
npm install -D ts-node-dev @types/node
# ts-node-dev → equivalente ao nodemon para TypeScript

Tipos primitivos e básicos

// ── Primitivos ──────────────────────────────────────
let nome: string = "Ana";
let idade: number = 28;
let ativo: boolean = true;
let nada: null = null;
let indefinido: undefined = undefined;

// Inferência de tipos — TypeScript deduz o tipo automaticamente
let cidade = "São Paulo";     // TypeScript infere: string
let pontos = 100;             // TypeScript infere: number
cidade = 42;                  // ❌ Erro: não pode ser number

// ── Arrays ─────────────────────────────────────────
let numeros: number[] = [1, 2, 3];
let nomes: string[] = ["Ana", "Bruno"];
let misturado: (string | number)[] = ["Ana", 42];

// Alternativa com genérico
let ids: Array<number> = [1, 2, 3];

// ── Tuplas — array com tipos fixos por posição ─────
let par: [string, number] = ["Ana", 28];
let coordenada: [number, number, number] = [10.5, -23.4, 0];

// ── any — o tipo que desabilita TypeScript ─────────
let qualquerCoisa: any = "texto";
qualquerCoisa = 42;           // ok — mas perde a proteção do TS
qualquerCoisa = { a: 1 };     // ok — mas evite ao máximo

// ── unknown — alternativa segura ao any ────────────
let entrada: unknown = obterEntradaExterna();
// entrada.toUpperCase();     // ❌ Erro — não sabe o tipo ainda
if (typeof entrada === "string") {
  entrada.toUpperCase();      // ✅ verificou o tipo primeiro
}

// ── never — código que nunca retorna ───────────────
function lancarErro(msg: string): never {
  throw new Error(msg); // nunca retorna normalmente
}

Interfaces — definindo contratos

// Interface define a "forma" de um objeto
interface Usuario {
  id: number;
  nome: string;
  email: string;
  idade?: number;              // ? = opcional
  readonly criadoEm: Date;    // readonly = imutável após criação
}

// Usando
const usuario: Usuario = {
  id: 1,
  nome: "Ana Paula",
  email: "ana@email.com",
  criadoEm: new Date(),
};

usuario.nome = "Ana";          // ✅ pode modificar
// usuario.criadoEm = new Date(); // ❌ readonly!

// Interface de função
interface Comparador {
  (a: number, b: number): number;
}

const ordenarCrescente: Comparador = (a, b) => a - b;

// Interface com métodos
interface Repositorio<T> {
  buscarPorId(id: number): Promise<T | null>;
  listar(): Promise<T[]>;
  salvar(item: T): Promise<T>;
  remover(id: number): Promise<void>;
}

// Extendendo interfaces
interface UsuarioAdmin extends Usuario {
  permissoes: string[];
  nivel: "super" | "comum";
}

// Implementando interface em classe
class UsuarioRepositorio implements Repositorio<Usuario> {
  async buscarPorId(id: number): Promise<Usuario | null> {
    // implementação...
    return null;
  }

  async listar(): Promise<Usuario[]> {
    return [];
  }

  async salvar(usuario: Usuario): Promise<Usuario> {
    return usuario;
  }

  async remover(id: number): Promise<void> {
    // implementação...
  }
}

Type Aliases — nomes para tipos

// Type alias — nome para qualquer tipo
type ID = number | string;
type Email = string;
type Status = "pendente" | "ativo" | "inativo";  // union literal

type Coordenada = {
  lat: number;
  lon: number;
};

type Callback<T> = (erro: Error | null, resultado: T | null) => void;

// Usando
let id: ID = 42;
id = "abc-123";               // também válido

let status: Status = "ativo";
// status = "deletado";       // ❌ não está no union

// ── Interface vs Type — quando usar cada um ─────────
// Interface: objetos e classes → prefira para APIs públicas
// Type: unions, intersections, primitivos → mais versátil

// Intersection types — combina tipos
type UsuarioComToken = Usuario & {
  token: string;
  expiraEm: Date;
};

Generics — tipos reutilizáveis

Generics permitem escrever código que funciona com qualquer tipo mantendo segurança:

// Função genérica
function primeiroItem<T>(array: T[]): T | undefined {
  return array[0];
}

const num = primeiroItem([1, 2, 3]);     // TypeScript infere: number
const str = primeiroItem(["a", "b"]);    // TypeScript infere: string
const vazio = primeiroItem([]);          // TypeScript infere: undefined

// Resposta de API genérica
interface RespostaAPI<T> {
  dados: T;
  sucesso: boolean;
  mensagem: string;
  timestamp: string;
}

type RespostaUsuario = RespostaAPI<Usuario>;
type RespostaLista = RespostaAPI<Usuario[]>;

// Função com múltiplos genéricos
function mapear<Entrada, Saida>(
  array: Entrada[],
  transformar: (item: Entrada) => Saida
): Saida[] {
  return array.map(transformar);
}

const nomes = mapear(
  [{ id: 1, nome: "Ana" }, { id: 2, nome: "Bruno" }],
  (u) => u.nome
);
// nomes: string[] — TypeScript inferiu!

// Generic com restrição (extends)
function buscarPropriedade<T, K extends keyof T>(obj: T, chave: K): T[K] {
  return obj[chave];
}

const usuario = { nome: "Ana", idade: 28, ativo: true };
const nome = buscarPropriedade(usuario, "nome");     // string
const idade = buscarPropriedade(usuario, "idade");   // number
// buscarPropriedade(usuario, "inexistente");         // ❌ Erro

Utility Types — tipos prontos do TypeScript

interface Usuario {
  id: number;
  nome: string;
  email: string;
  senha: string;
  ativo: boolean;
}

// Partial<T> — todos os campos opcionais
type AtualizacaoUsuario = Partial<Usuario>;
// { id?: number, nome?: string, email?: string, ... }

// Required<T> — todos os campos obrigatórios
type UsuarioCompleto = Required<AtualizacaoUsuario>;

// Readonly<T> — todos os campos imutáveis
type UsuarioImutavel = Readonly<Usuario>;

// Pick<T, K> — seleciona apenas alguns campos
type UsuarioPublico = Pick<Usuario, "id" | "nome" | "email">;
// { id: number, nome: string, email: string }

// Omit<T, K> — remove campos
type UsuarioSemSenha = Omit<Usuario, "senha">;
// { id: number, nome: string, email: string, ativo: boolean }

// Record<K, V> — objeto com chaves e valores tipados
type MapaDeErros = Record<string, string[]>;
// { email: ["inválido"], nome: ["muito curto"] }

type StatusPorId = Record<number, "ativo" | "inativo">;

// Exclude<T, U> — remove tipos de um union
type SemNull = Exclude<string | number | null | undefined, null | undefined>;
// string | number

// NonNullable<T> — remove null e undefined
type Obrigatorio = NonNullable<string | null | undefined>;
// string

// ReturnType<T> — tipo de retorno de uma função
function criarUsuario() {
  return { id: 1, nome: "Ana" };
}
type TipoRetorno = ReturnType<typeof criarUsuario>;
// { id: number, nome: string }

// Parameters<T> — tipos dos parâmetros de uma função
type Params = Parameters<typeof calcularDesconto>;
// [preco: number, percentual: number]

Enums — conjuntos de constantes nomeadas

// Enum numérico (padrão)
enum Status {
  Pendente,     // 0
  Ativo,        // 1
  Inativo,      // 2
  Bloqueado,    // 3
}

const status = Status.Ativo;  // 1
console.log(Status[1]);        // "Ativo" — mapeamento reverso

// Enum de string (mais legível, recomendado)
enum Prioridade {
  Baixa = "baixa",
  Media = "media",
  Alta = "alta",
  Critica = "critica",
}

// Const enum — o valor é embutido direto na compilação, sem gerar objeto.
// Em compensação, ele não funciona com isolatedModules, que é o padrão de
// quem compila com esbuild, swc ou Vite — ou seja, quase todo projeto novo.
const enum DirecaoHTTP {
  Get = "GET",
  Post = "POST",
  Put = "PUT",
  Delete = "DELETE",
}

// Union literal — alternativa moderna ao enum
type PrioridadeType = "baixa" | "media" | "alta" | "critica";
// Mais simples, sem overhead, amplamente preferido em código moderno

Classes com TypeScript

class Tarefa {
  // Propriedades com modificadores de acesso
  public readonly id: number;
  public titulo: string;
  public descricao: string;
  private _concluida: boolean = false;  // _ = convenção para privado
  protected criadoEm: Date;

  // Construtor com parâmetros tipados
  constructor(
    id: number,
    titulo: string,
    descricao: string = "",
  ) {
    this.id = id;
    this.titulo = titulo;
    this.descricao = descricao;
    this.criadoEm = new Date();
  }

  // Getter — propriedade calculada
  get concluida(): boolean {
    return this._concluida;
  }

  // Setter — com validação
  set concluida(valor: boolean) {
    if (this._concluida && !valor) {
      throw new Error("Não é possível reabrir uma tarefa concluída.");
    }
    this._concluida = valor;
  }

  // Método com tipo de retorno explícito
  concluir(): void {
    this._concluida = true;
  }

  // Método estático
  static criar(titulo: string): Tarefa {
    return new Tarefa(Date.now(), titulo);
  }

  // Convertendo para JSON
  toJSON(): object {
    return {
      id: this.id,
      titulo: this.titulo,
      descricao: this.descricao,
      concluida: this._concluida,
      criadoEm: this.criadoEm,
    };
  }
}

// Herança
class TarefaUrgente extends Tarefa {
  public prazo: Date;

  constructor(id: number, titulo: string, prazo: Date) {
    super(id, titulo);  // chama construtor da classe pai
    this.prazo = prazo;
  }

  get atrasada(): boolean {
    return !this.concluida && new Date() > this.prazo;
  }

  // Override do método pai
  override toJSON(): object {
    return {
      ...super.toJSON(),
      prazo: this.prazo,
      atrasada: this.atrasada,
    };
  }
}

// Shorthand de construtor — elimina boilerplate
class Produto {
  constructor(
    public readonly id: number,
    public nome: string,
    private preco: number,
    protected estoque: number = 0,
  ) {}

  aumentarEstoque(quantidade: number): void {
    this.estoque += quantidade;
  }
}

TypeScript com Express — tipagem de req e res

// src/types/express.d.ts — extendendo os tipos do Express
import { Usuario } from "../models/Usuario";

declare global {
  namespace Express {
    interface Request {
      usuario?: Usuario;  // adicionado pelo middleware de auth
    }
  }
}
// src/controllers/authController.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { Usuario, IUsuario } from "../models/Usuario";

// Tipando o corpo da requisição
interface LoginBody {
  email: string;
  senha: string;
}

interface RegistroBody {
  nome: string;
  email: string;
  senha: string;
}

// Função tipada — TypeScript sabe os tipos de tudo
export async function login(
  req: Request<{}, {}, LoginBody>,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const { email, senha } = req.body;  // tipado como LoginBody

    if (!email || !senha) {
      res.status(400).json({ erro: "Email e senha são obrigatórios." });
      return;
    }

    const usuario = await Usuario.findOne({ email }).select("+senha");
    if (!usuario) {
      res.status(401).json({ erro: "Credenciais inválidas." });
      return;
    }

    const senhaCorreta = await usuario.verificarSenha(senha);
    if (!senhaCorreta) {
      res.status(401).json({ erro: "Credenciais inválidas." });
      return;
    }

    const token = jwt.sign(
      { id: usuario._id },
      process.env.JWT_SECRET as string,
      { expiresIn: "7d" }
    );

    res.json({ token, usuario });
  } catch (erro) {
    next(erro);
  }
}

// Tipando parâmetros de rota
interface ParamsComId {
  id: string;
}

export async function buscarPorId(
  req: Request<ParamsComId>,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const { id } = req.params;  // tipado como string
    const usuario = await Usuario.findById(id);

    if (!usuario) {
      res.status(404).json({ erro: "Usuário não encontrado." });
      return;
    }

    res.json(usuario);
  } catch (erro) {
    next(erro);
  }
}

Tipos para o Mongoose

// src/models/Usuario.ts
import mongoose, { Document, Model, Schema } from "mongoose";
import bcrypt from "bcrypt";

// Interface do documento
export interface IUsuario extends Document {
  nome: string;
  email: string;
  senha: string;
  ativo: boolean;
  createdAt: Date;
  updatedAt: Date;

  // Métodos de instância
  verificarSenha(senha: string): Promise<boolean>;
}

// Interface do Model (métodos estáticos)
interface IUsuarioModel extends Model<IUsuario> {
  buscarPorEmail(email: string): Promise<IUsuario | null>;
}

const usuarioSchema = new Schema<IUsuario, IUsuarioModel>(
  {
    nome: { type: String, required: true },
    email: { type: String, required: true, unique: true },
    senha: { type: String, required: true, select: false },
    ativo: { type: Boolean, default: true },
  },
  { timestamps: true }
);

usuarioSchema.pre("save", async function (next) {
  if (!this.isModified("senha")) return next();
  this.senha = await bcrypt.hash(this.senha, 12);
  next();
});

usuarioSchema.methods.verificarSenha = async function (
  this: IUsuario,
  senha: string
): Promise<boolean> {
  return bcrypt.compare(senha, this.senha);
};

usuarioSchema.statics.buscarPorEmail = function (
  email: string
): Promise<IUsuario | null> {
  return this.findOne({ email }).select("+senha");
};

export const Usuario = mongoose.model<IUsuario, IUsuarioModel>(
  "Usuario",
  usuarioSchema
);

Boas práticas com TypeScript

// ✅ 1. Prefira interfaces para objetos públicos
//    Prefira types para unions e aliases
interface Config { porta: number; dbUrl: string; }
type Ambiente = "development" | "production" | "test";

// ✅ 2. Evite any — use unknown quando o tipo é incerto
function parsearJSON(texto: string): unknown {
  return JSON.parse(texto);  // retorna unknown, não any
}

// ✅ 3. Use as const para objetos e arrays imutáveis
const ROTAS = {
  LOGIN: "/auth/login",
  REGISTRO: "/auth/registrar",
} as const;

// ROTAS.LOGIN tem o tipo "/auth/login" (literal), não string

// ✅ 4. Non-null assertion (!) — use com cuidado
const elemento = document.querySelector("#app")!; // sabe que existe

// ✅ 5. Type guards — verificações de tipo em runtime
function ehString(valor: unknown): valor is string {
  return typeof valor === "string";
}

function ehUsuario(valor: unknown): valor is IUsuario {
  return (
    typeof valor === "object" &&
    valor !== null &&
    "nome" in valor &&
    "email" in valor
  );
}

// ✅ 6. Nunca ignore erros do TypeScript com @ts-ignore
// @ts-ignore  ← ❌ esconde o problema
// @ts-expect-error ← melhor: documenta que é intencional

// ✅ 7. Ative strict: true no tsconfig — nunca desabilite

Tarefa para você

Migre a API REST do Módulo 4 para TypeScript:

// 1. Configure o tsconfig.json e instale as dependências:
//    npm install -D typescript @types/node @types/express
//    npm install -D @types/bcrypt @types/jsonwebtoken
//    npm install -D ts-node-dev

// 2. Renomeie todos os arquivos .js para .ts

// 3. Crie interfaces para todos os modelos:
//    IUsuario, ITarefa — com todos os campos e métodos

// 4. Crie um arquivo src/types/index.ts com:
//    - Todos os tipos utilitários do projeto
//    - UsuarioPublico (omite senha)
//    - TarefaComUsuario (populate)
//    - RespostaAPI<T> genérica
//    - ErroValidacao

// 5. Tipe todos os controllers:
//    - req.body com interfaces específicas
//    - req.params com tipos corretos
//    - Valores de retorno explícitos

// 6. Crie um enum para Status da Tarefa e Prioridade

// 7. Compile com npm run build e corrija todos os erros
//    (sem usar any como atalho!)

// 8. Adicione ao tsconfig: "noImplicitAny": true
//    e veja o TypeScript te ajudar a encontrar pontos não tipados
Ver solução — a migração completa — tsconfig, tipos, models, middleware e controller
// ---- tsconfig.json
// {
//   "compilerOptions": {
//     "target": "ES2022",
//     "module": "node18",
//     "rootDir": "src",
//     "outDir": "dist",
//     "esModuleInterop": true,
//     "forceConsistentCasingInFileNames": true,
//
//     "strict": true,            // liga tudo abaixo de uma vez
//     "noImplicitAny": true,     // 8 — explícito, apesar de já vir no strict
//     "strictNullChecks": true,
//     "noUnusedLocals": true,
//     "noUnusedParameters": true,
//     "noFallthroughCasesInSwitch": true,
//
//     "skipLibCheck": true,      // não valide os .d.ts das dependências
//     "sourceMap": true
//   },
//   "include": ["src/**/*.ts"],
//   "exclude": ["node_modules", "dist"]
// }
//
// scripts:
//   "build":     "tsc"
//   "typecheck": "tsc --noEmit"
//   "dev":       "ts-node-dev --respawn src/index.ts"

// ---- src/types/enums.ts
// 6 — os enums
export enum StatusTarefa {
  Pendente = "pendente",
  EmProgresso = "em_progresso",
  Concluida = "concluida",
  Cancelada = "cancelada",
}

export enum Prioridade {
  Baixa = "baixa",
  Media = "media",
  Alta = "alta",
}

// Enum de string, e não numérico: o valor gravado no Mongo é "pendente", que
// se lê num dump. Enum numérico gravaria 0, e um dia alguém insere um status
// no meio da lista e renumera silenciosamente o banco inteiro.

// ---- src/models/Usuario.ts
// 3 — as interfaces dos modelos
import mongoose, { Schema, Model, HydratedDocument } from "mongoose";
import bcrypt from "bcryptjs";

export interface IUsuario {
  nome: string;
  email: string;
  senha: string;
  ativo: boolean;
  createdAt: Date;
  updatedAt: Date;
}

// Métodos ficam numa interface separada: IUsuario descreve o DOCUMENTO CRU,
// que é o que sai de um .lean() e o que entra num create().
export interface IUsuarioMetodos {
  verificarSenha(senhaDigitada: string): Promise<boolean>;
}

export type UsuarioDoc = HydratedDocument<IUsuario, IUsuarioMetodos>;

type UsuarioModel = Model<IUsuario, Record<string, never>, IUsuarioMetodos>;

const usuarioSchema = new Schema<IUsuario, UsuarioModel, IUsuarioMetodos>(
  {
    nome: { type: String, required: true, trim: true, minlength: 2 },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i, "Email inválido."],
    },
    senha: { type: String, required: true, minlength: 6, select: false },
    ativo: { type: Boolean, default: true },
  },
  { timestamps: true, versionKey: false }
);

// Sem o parâmetro `next`: em hook async, o Mongoose 9 passa um objeto de
// opções no lugar dele, e `next()` viraria "next is not a function".
usuarioSchema.pre("save", async function () {
  if (!this.isModified("senha")) return;
  this.senha = await bcrypt.hash(this.senha, 12);
});

// O `this: UsuarioDoc` explícito é o que faz `this.senha` ter tipo aqui
// dentro — em function comum, o TypeScript não adivinha o dono do método.
usuarioSchema.methods.verificarSenha = function (
  this: UsuarioDoc,
  senhaDigitada: string
): Promise<boolean> {
  return bcrypt.compare(senhaDigitada, this.senha);
};

export const Usuario = mongoose.model<IUsuario, UsuarioModel>("Usuario", usuarioSchema);

// ---- src/models/Tarefa.ts
import mongoose, { Schema, Types, HydratedDocument } from "mongoose";
import { StatusTarefa, Prioridade } from "../types/enums";

export interface ITarefa {
  titulo: string;
  descricao: string;
  status: StatusTarefa;
  prioridade: Prioridade;
  prazo: Date | null;
  tags: string[];
  usuario: Types.ObjectId;
  createdAt: Date;
  updatedAt: Date;
}

// Virtuais também ficam à parte: `atrasada` não existe no banco, e incluí-la
// em ITarefa faria o create() exigir um campo que ninguém pode passar.
export interface ITarefaVirtuais {
  atrasada: boolean;
}

export type TarefaDoc = HydratedDocument<ITarefa, ITarefaVirtuais>;

const tarefaSchema = new Schema<ITarefa>(
  {
    titulo: { type: String, required: true, trim: true, maxlength: 200 },
    descricao: { type: String, trim: true, maxlength: 2000, default: "" },
    status: {
      type: String,
      enum: Object.values(StatusTarefa),
      default: StatusTarefa.Pendente,
    },
    prioridade: {
      type: String,
      enum: Object.values(Prioridade),
      default: Prioridade.Media,
    },
    prazo: { type: Date, default: null },
    tags: { type: [String], default: [] },
    usuario: { type: Schema.Types.ObjectId, ref: "Usuario", required: true },
  },
  { timestamps: true, versionKey: false }
);

tarefaSchema.index({ usuario: 1, status: 1 });

tarefaSchema.virtual("atrasada").get(function (this: ITarefa): boolean {
  if (!this.prazo || this.status === StatusTarefa.Concluida) return false;
  return new Date() > this.prazo;
});

tarefaSchema.set("toJSON", { virtuals: true });

export const Tarefa = mongoose.model<ITarefa>("Tarefa", tarefaSchema);

// ---- src/types/index.ts
// 4 — os tipos utilitários do projeto
import { Request } from "express";
import { Types } from "mongoose";
import { IUsuario, UsuarioDoc } from "../models/Usuario";
import { ITarefa } from "../models/Tarefa";
import { StatusTarefa, Prioridade } from "./enums";

export { StatusTarefa, Prioridade };

/** O usuário como ele pode sair na resposta HTTP: sem senha, com id string. */
export type UsuarioPublico = Omit<IUsuario, "senha"> & { id: string };

/** Tarefa depois do populate("usuario") — o ObjectId virou objeto. */
export type TarefaComUsuario = Omit<ITarefa, "usuario"> & {
  _id: Types.ObjectId;
  usuario: UsuarioPublico;
};

/**
 * Envelope de toda resposta. É união discriminada de propósito: com `ok`
 * como discriminante, quem consome só alcança `dados` depois de checar
 * `if (resposta.ok)`. O compilador cobra o tratamento do erro.
 */
export type RespostaAPI<T> =
  | { ok: true; dados: T; paginacao?: Paginacao }
  | { ok: false; erro: string; detalhes?: string[] };

export interface Paginacao {
  total: number;
  pagina: number;
  por_pagina: number;
  total_paginas: number;
}

export interface ErroValidacao {
  campo: string;
  mensagem: string;
  valorRecebido?: unknown;   // unknown, nunca any: obriga a estreitar antes de usar
}

/** Requisição que já passou pelo middleware `autenticar`. */
export interface RequisicaoAutenticada<
  Corpo = unknown,
  Params extends Record<string, string> = Record<string, string>,
  Query = unknown,
> extends Request<Params, unknown, Corpo, Query> {
  usuario: UsuarioDoc;
}

export interface CorpoCriarTarefa {
  titulo: string;
  descricao?: string;
  status?: StatusTarefa;
  prioridade?: Prioridade;
  prazo?: string | null;     // string: JSON não tem Date
  tags?: string[];
}

export interface QueryListarTarefas {
  status?: StatusTarefa;
  prioridade?: Prioridade;
  busca?: string;
  pagina?: string;           // string: query string SEMPRE chega como texto
  por_pagina?: string;
  ordenar?: string;
}

// ---- src/middlewares/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt, { JwtPayload } from "jsonwebtoken";
import { Usuario } from "../models/Usuario";
import { RequisicaoAutenticada } from "../types";

interface PayloadToken extends JwtPayload {
  id: string;
}

function segredo(): string {
  const valor = process.env.JWT_SECRET;
  // process.env é string | undefined. Sem esta checagem,
  // jwt.verify(token, undefined) só falharia em produção.
  if (!valor) throw new Error("JWT_SECRET não configurado.");
  return valor;
}

export async function autenticar(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    res.status(401).json({ ok: false, erro: "Token de autenticação ausente." });
    return;   // `return res.status(...)` não compila: o retorno é Promise<void>
  }

  let payload: PayloadToken;
  try {
    payload = jwt.verify(authHeader.slice(7), segredo()) as PayloadToken;
  } catch (erro) {
    // `erro` é unknown no catch. instanceof estreita sem cast.
    const expirado = erro instanceof jwt.TokenExpiredError;
    res.status(401).json({
      ok: false,
      erro: expirado ? "Token expirado. Faça login novamente." : "Token inválido.",
    });
    return;
  }

  const usuario = await Usuario.findById(payload.id);
  if (!usuario || !usuario.ativo) {
    res.status(401).json({ ok: false, erro: "Usuário não encontrado ou inativo." });
    return;
  }

  (req as RequisicaoAutenticada).usuario = usuario;
  next();
}

// ---- src/controllers/tarefaController.ts
// 5 — controllers tipados: corpo, params, query e retorno
import { Response, NextFunction } from "express";
import { QueryFilter } from "mongoose";
import { Tarefa, ITarefa } from "../models/Tarefa";
import {
  RequisicaoAutenticada,
  RespostaAPI,
  CorpoCriarTarefa,
  QueryListarTarefas,
} from "../types";

type RespostaListar = Response<RespostaAPI<ITarefa[]>>;
type RespostaTarefa = Response<RespostaAPI<ITarefa>>;

export async function listar(
  req: RequisicaoAutenticada<unknown, Record<string, string>, QueryListarTarefas>,
  res: RespostaListar,
  next: NextFunction
): Promise<void> {
  try {
    const { status, prioridade, busca, pagina, por_pagina, ordenar } = req.query;

    const filtros: QueryFilter<ITarefa> = { usuario: req.usuario._id };
    if (status) filtros.status = status;
    if (prioridade) filtros.prioridade = prioridade;
    if (busca) filtros.titulo = { $regex: busca, $options: "i" };

    const limite = Math.min(Number(por_pagina) || 10, 50);
    const paginaAtual = Math.max(Number(pagina) || 1, 1);

    const [tarefas, total] = await Promise.all([
      Tarefa.find(filtros)
        .sort(ordenar ?? "-createdAt")
        .skip((paginaAtual - 1) * limite)
        .limit(limite)
        .lean<ITarefa[]>(),         // sem o genérico, lean() devolve o tipo cru
      Tarefa.countDocuments(filtros),
    ]);

    res.json({
      ok: true,
      dados: tarefas,
      paginacao: {
        total,
        pagina: paginaAtual,
        por_pagina: limite,
        total_paginas: Math.ceil(total / limite),
      },
    });
  } catch (erro) {
    next(erro);
  }
}

export async function criar(
  req: RequisicaoAutenticada<CorpoCriarTarefa>,
  res: RespostaTarefa,
  next: NextFunction
): Promise<void> {
  try {
    const { titulo, descricao, status, prioridade, prazo, tags } = req.body;

    const tarefa = await Tarefa.create({
      titulo,
      descricao,
      status,
      prioridade,
      prazo: prazo ? new Date(prazo) : null,
      tags,
      usuario: req.usuario._id,
    });

    res.status(201).json({ ok: true, dados: tarefa.toObject() });
  } catch (erro) {
    next(erro);
  }
}

// ---- verificacao.ts
// 7 e 8 — `npx tsc --noEmit` no projeto acima: sai limpo, sem um `any`.
//
// Para ver o compilador trabalhando, um arquivo com os erros clássicos:
//
//   export function resumir(tarefas) {
//     return tarefas.map((t) => t.titulo);
//   }
//
//   export async function primeiroTitulo(): Promise<string> {
//     const tarefa = await Tarefa.findOne();
//     return tarefa.titulo;
//   }
//
//   export function ehFinal(status: StatusTarefa): boolean {
//     return status === "arquivada";
//   }
//
// A saída:
//
//   src/demo-erros.ts(4,25): error TS7006: Parameter 'tarefas' implicitly has an 'any' type.
//   src/demo-erros.ts(5,23): error TS7006: Parameter 't' implicitly has an 'any' type.
//   src/demo-erros.ts(10,10): error TS18047: 'tarefa' is possibly 'null'.
//   src/demo-erros.ts(14,10): error TS2367: This comparison appears to be unintentional
//                             because the types 'StatusTarefa' and '"arquivada"' have no overlap.
//
// O TS18047 é o que paga a migração sozinho: `findOne()` devolve
// `TarefaDoc | null`, e o "Cannot read properties of null" que você só veria
// em produção — no dia em que o id não existir — vira erro de compilação.
// O TS2367 é o segundo: um status escrito errado deixa de ser um `if` que
// nunca entra e passa a ser um build que não passa.
//
// Ao migrar de verdade, dois nomes mudaram no Mongoose 9 e valem o aviso:
// `FilterQuery` virou `QueryFilter`, e hook async não recebe mais `next`.

A tentação, no meio da migração, é calar o compilador com any — e aí o projeto fica com a sintaxe do TypeScript e a segurança do Javascript. Quando o tipo é mesmo desconhecido, o certo é unknown: ele obriga a estreitar antes de usar, em vez de liberar tudo. E o ponto que passa despercebido: o TypeScript não valida nada em tempo de execução. req.body é CorpoCriarTarefa porque você declarou, não porque alguém conferiu — o cliente ainda pode mandar o que quiser. Tipo é contrato com o compilador; na fronteira da API, continua sendo preciso validar de verdade.

TypeScript não roda: ele é apagado na compilação, e o que sobra é o JavaScript de sempre. Daí decorrem duas consequências que orientam todo o resto — nenhum tipo protege contra o que chega da rede em tempo de execução, o que mantém necessária a validação da resposta de uma API; e o any não é um tipo, é a desistência de ter um, apagando a verificação justamente onde ela seria mais útil.

Fontes e Referências

Exercícios

Exercício 1

Esta é a função sem tipos. O que cada chamada devolve — e qual delas é a mais perigosa?

function calcularDesconto(preco, percentual) {
  return preco - (preco * percentual / 100);
}

calcularDesconto("100", 10);      // A
calcularDesconto("100,50", 10);   // B
calcularDesconto(100, "dez");     // C
Ver resposta

✓ Resposta: A devolve 90, B e C devolvem NaN — e a perigosa é a A, justamente porque funciona. Os operadores - e * convertem os operandos em número antes de operar; só o + concatena quando um dos lados é string. Então uma string numérica atravessa a função inteira e produz o resultado certo, o que faz o teste passar, a tela mostrar o valor correto e ninguém desconfiar de nada. O defeito só aparece quando a entrada muda de forma — um preço com vírgula decimal, vindo de um formulário brasileiro ou de um CSV, e a conta vira NaN, que se propaga em silêncio por todo o cálculo até chegar ao usuário como "R$ NaN". B e C são os casos honestos: falham cedo e de forma visível. A moral vale para além deste exemplo: coerção implícita não é perigosa por dar erro, é perigosa por não dar — ela adia a falha até um ponto onde a causa já não é rastreável. É esse adiamento que o TypeScript elimina, recusando a chamada no editor, antes de existir execução.

Exercício 2

O código compila sem nenhum erro e quebra em produção com TypeError: usuario.nome.toUpperCase is not a function. Como isso é possível, se o tipo estava declarado?

interface Usuario {
  id: number;
  nome: string;
  idade: number;
}

const resposta = await fetch("/api/usuarios/1");
const usuario = (await resposta.json()) as Usuario;

console.log(usuario.nome.toUpperCase());
Ver resposta

✓ Resposta: Porque o tipo não existe em tempo de execução. Todo o sistema de tipos é apagado na compilação — o que roda é JavaScript puro, sem nenhuma verificação. E o as Usuario não checa coisa alguma: ele é uma afirmação, uma forma de dizer ao compilador "confie em mim, é disto que se trata". Se a API devolver { "nome": null }, ou { "name": "Ana" } em inglês, ou um objeto de erro, o compilador continua satisfeito e o programa quebra na primeira propriedade acessada. Essa é a fronteira que todo projeto TypeScript precisa ter clara: dentro do código, os tipos garantem; na borda — rede, arquivo, formulário, banco, variável de ambiente — eles não garantem nada. A solução é validar de verdade no ponto de entrada, com um type guard escrito à mão ou, na prática, com uma biblioteca de esquema como o Zod, que faz a checagem em tempo de execução e deriva o tipo estático a partir do mesmo esquema — uma declaração só, válida nos dois mundos. Vale ainda a distinção entre as duas ferramentas: as silencia o compilador, unknown mais um type guard obriga a provar. A primeira é conveniência; a segunda é segurança.

Exercício 3

Duas funções recebem dados de fora. Por que a segunda é considerada correta e a primeira, uma desistência?

function processarA(dados: any) {
  return dados.usuario.perfil.nome.trim();
}

function processarB(dados: unknown) {
  return dados.usuario.perfil.nome.trim();
}
Ver resposta

✓ Resposta: A primeira compila e a segunda não — e é exatamente esse o ponto. Com any, o TypeScript desliga toda verificação naquele valor: a cadeia inteira de propriedades é aceita sem perguntas, e se qualquer elo do caminho for undefined, o erro aparece só em produção. Pior, any é contagioso: o retorno de processarA também é any, e ele contamina tudo o que tocar adiante, o que faz um único any mal colocado abrir um buraco em boa parte do projeto. Já unknown diz "não sei o que é isto, e você também não" — qualquer acesso é recusado até que o tipo seja estreitado, com typeof, in, instanceof ou um type guard. A versão correta da segunda função é verificar antes de usar. A regra prática: any é a renúncia a ter tipo, unknown é a admissão de que ainda não se sabe qual é — e o segundo é sempre o que se quer para dado externo. Quando o any for mesmo inevitável, vale isolá-lo numa função pequena, converter o resultado para um tipo real ali dentro e nunca deixá-lo vazar para o resto.

Exercício 4

O tsconfig.json tem "strict": false. O código abaixo compila. Onde ele quebra em execução?

function saudar(usuario: { nome: string }) {
  return `Olá, ${usuario.nome.toUpperCase()}`;
}

const encontrado = usuarios.find(u => u.id === 999);
saudar(encontrado);
Ver resposta

✓ Resposta: Quebra na primeira linha da função, com Cannot read properties of undefined (reading 'toUpperCase'). O find devolve Usuario | undefined, porque pode não encontrar nada — e com strict desligado, mais precisamente com strictNullChecks desligado, o TypeScript trata null e undefined como valores válidos de qualquer tipo. A verificação some, a chamada é aceita, e o TypeScript deixa de proteger contra a classe de erro mais comum que existe em JavaScript, que é justamente acessar propriedade de algo que não veio. Com strict: true, o compilador recusa a chamada e obriga a tratar o caso: um if (encontrado), um encontrado?.nome, ou um valor padrão. É por isso que a recomendação do artigo — ativar strict e nunca desligar — não é preciosismo: sem ele, boa parte do benefício de adotar TypeScript simplesmente não existe. E a ressalva prática para quem está migrando um projeto antigo: ligar strict de uma vez costuma produzir centenas de erros. O caminho é ligar as verificações uma a uma, começando por strictNullChecks, que é a que dá o maior retorno, e deixar noImplicitAny para depois.

Exercício 5

O enum abaixo é usado para validar a entrada de uma API. Que valor inesperado passa na verificação?

enum Status {
  Pendente,   // 0
  Ativo,      // 1
  Inativo,    // 2
}

function atualizar(status: Status) {
  console.log("status:", status);
}

atualizar(Status.Ativo);  // ok
atualizar(7);             // ?
Ver resposta

✓ Resposta: Em versões anteriores à 5.0 do TypeScript, atualizar(7) era aceito: enums numéricos permitiam qualquer número, por causa da compatibilidade com o uso de enum como conjunto de flags combináveis por operações de bit. Da 5.0 em diante isso passou a ser erro para enums sem valores calculados, mas o histórico explica por que o padrão caiu em desuso. E há outras características desconfortáveis: o enum numérico gera um objeto em tempo de execução com mapeamento reversoStatus[1] devolve "Ativo" —, o que faz Object.keys(Status) retornar seis entradas em vez de três e quebra qualquer iteração ingênua; e o valor que trafega na API vira 0, 1, 2, números sem significado no banco e no log, que desalinham para sempre se alguém inserir um item no meio da lista. A alternativa moderna é a union de literaistype Status = "pendente" | "ativo" | "inativo" —, que não gera nada em tempo de execução, recusa qualquer valor fora da lista e grava texto legível. Quando for preciso iterar sobre as opções, o padrão é declarar o array com as const e derivar o tipo dele com typeof LISTA[number], mantendo uma fonte única. O enum de string, que o artigo também apresenta, é um meio-termo aceitável: não tem mapeamento reverso e grava texto, mas ainda existe em execução.

Comentários

Mais em Javascript

Criando um servidor HTTP com Node.js puro
Criando um servidor HTTP com Node.js puro

Um require de http, uma função de dois argumentos, e o servidor está de pé. O…

Temporizadores: setTimeout e setInterval
Temporizadores: setTimeout e setInterval

Um relógio que desconta um segundo a cada tique sempre atrasa: setInterval…

Tratamento de Erros com try, catch e finally
Tratamento de Erros com try, catch e finally

Um catch vazio transforma falha em silêncio: a tela não reage, o log não…