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

Revisão + Projeto Final: SPA Completa
Revisão + Projeto Final: SPA Completa

As cinco peças do módulo em uma aplicação só: rotas com layout e proteção…

Dominando o JavaScript
Dominando o JavaScript

Cinquenta e dois artigos em seis módulos, do primeiro console.log ao dashboard…

Projeto Final: Revisão Completa e Aplicação de Produção
Projeto Final: Revisão Completa e Aplicação de Produção

O fim da série reúne tudo numa aplicação de produção: React com rotas e…