Skip to content

Garantindo dados consistentes com tipos nominais no Typescript

Atualizado: at 14:44

Aqui na Salvy amamos usar tipos nominais como uma estratégia para garantir que estamos sempre lidando com dados válidos quando utilizamos e-mails, números de telefone, CPF, CNPJ, etc.

Tipos nominais X Tipos estruturais

O Typescript é uma linguagem com tipagem estrutural, ou seja, se dois objetos implementam as mesmas propriedades e métodos, são considerados equivalentes. Por exemplo, se dentro de uma aplicação, temos um usuário vindo do banco de dados:

interface DatabaseUser {
  name: string;
  email: string;
}

E um usuário exibido na interface:

interface FrontendUser {
  name: string;
  email: string;
}

O Typescript não vai reclamar se passarmos um DatabaseUser onde um FrontendUser é esperado, pois ambos possuem as mesmas propriedades e tipos:

const exibirUsuario = (usuario: FrontendUser) => {
  console.log(usuario.name);
};

const databaseUser: DatabaseUser = {
  id: "123",
  name: "Danilo",
  email: "danilo@building.salvy.com.br",
};

// Não gera erro, mesmo passando um DatabaseUser
exibirUsuario(databaseUser);

Isso é bem diferente de como o Java ou C# funcionam, onde precisamos explicitamente dizer que um tipo é igual a outro, mesmo que tenham a mesma estrutura.

Essa flexibilidade traz muito poder para a linguagem e para o programador, mas também pode ser um problema pois perdemos algumas das garantias que um tipo pode ter.

Analisando o nosso campo email: Um e-mail é de fato uma string, mas ele não é qualquer string, pois precisa satisfazer algumas regras para ser considerado válido (como essa regex bizarra). Portanto, ao definirmos o tipo de email para string, temos os seguintes pontos a considerar:

  1. Abre a possibilidade de ser qualquer string, inclusive "", "@".
  2. Lidar com tipos string não me dá contexto sobre o conteúdo. Só tenho contexto se tiver acesso ao nome da variável email, que pode ter vários nomes dentro da aplicação.
  3. Posso misturar parâmetros sem querer: Dada a função enviarEmail, posso passar os parâmetros na ordem errada, sem que o Typescript reclame:
const enviarEmail = (para: string, assunto: string, corpo: string) => {
  // ...
};

enviarEmail("Olá amigo", "Olá", "danilo@building.salvy.com.br");

Tipos nominais

Vamos definir um tipo Email que é uma string, mas com um nome diferente:

type Email = Nominal<string, "email">;

Podemos utilizar esse tipo em qualquer lugar que usaríamos uma string, e se tentarmos passar uma string qualquer, o Typescript vai reclamar:

const enviarEmail = (para: Email, assunto: string, corpo: string) => {
  // ...
};

enviarEmail("Olá amigo", "Olá", "danilo@building.salvy.com.br");
//          ^^^^^^^^^^^
// Erro: Argument of type 'string' is not assignable to parameter of type 'Email'

Nominal é um utilitário que inserimos na pasta utils do nosso código (e no nosso SDK interno), que usa uns hacks de Typescript para garantir que um tipo seja igual somente a si mesmo:

/**
 * Constrói um tipo nominal para `T`.
 * Utilizado para previnir que qualquer valor do tipo `T` seja usado ou
 * modificado em lugares que não deveriam (por exemplo, `id`).
 *
 * @param T - o tipo da variavel (string, number, etc.)
 * @param N - o nome do tipo `Nominal` (id, email, etc.)
 * @returns um tipo que é somente igual a si mesmo, podendo ser utilizado como
 *          se fosse `T`
 */
export type Nominal<T, N extends string> = T & Tagged<N>;

/**
 * Esse é um utilitário para `Nominal` e não tem utilidade sozinho
 */
export declare class Tagged<N extends string> {
  protected _nominal_: N;
}

Para construir um Email, basta criar uma função de validação, que ao final faz um cast:

export function email(email: string): Email {
  const formattedEmail = email.trimStart().trimEnd().toLowerCase();
  const isValidEmail = EMAIL_REGEX.test(formattedEmail);
  if (!isValidEmail) {
    throw new Error(`Invalid email: ${formattedEmail}`);
  }

  // Casting para Email
  return formattedEmail as Email;
}

De quebra, implementar um transformer para Zod:

import { z } from "zod";

export function zodTransformer<InputType, OutputType>(
  transformer: (val: InputType) => OutputType,
  errorMap: (e: Error, ctx: z.RefinementCtx) => void
) {
  return (val: InputType, ctx: z.RefinementCtx) => {
    try {
      return transformer(val);
    } catch (e) {
      errorMap(e as Error, ctx);
    }

    return z.NEVER;
  };
}

export const zodEmailTransformer = zodTransformer(email, (e, ctx) =>
  ctx.addIssue({
    code: "invalid_string",
    validation: "email",
  })
);

const userSchema = z.object({
  name: z.string(),
  email: z.string().transform(zodEmailTransformer), // email vai ser do tipo Email
});

Conclusão

Utilizamos tipos nominais sempre que queremos garantir que um dado seja consistente e não seja confundido com outro do mesmo tipo. Isso é especialmente útil para campos como id, email, cpf, cnpj, telefone, etc.

Utilizamos exatamente a mesma regra de validação no nosso frontend e backend, através do nosso SDK interno.

Também utilizamos tipos nominais para padronizar a formatação desses valores: email é sempre transformado para minúsculas e sem espaços. CPF é salvo sem pontos e traços, mas pode ser formatado no frontend corretamente, etc.

Vou deixar aqui a implementação de alguns tipos nominais que usamos na Salvy:

CPF
import { cpf as cpfCValidator } from "cpf-cnpj-validator";
import { type z } from "zod";
import { zodTransformer } from "../utils/zod-transformer";
import { type Nominal } from "./nominal";

export type CPF = Nominal<string, "cpf">;

export function cpf(cpf: string): CPF {
  const stripped = cpf.replace(/\D/g, "");

  // Aqui vem uma função que valida o CPF, como o pacote `cpf-cnpj-validator`
  const isValidCpf = cpfCValidator.isValid(stripped);
  if (!isValidCpf) {
    throw new Error("Invalid CPF");
  }

  return cpf as CPF;
}

export const isCpf = (value: unknown): value is CPF =>
  typeof value === "string" && cpfCValidator.isValid(value.replace(/\D/g, ""));

export const zodCPFTransformer = zodTransformer(cpf, (_, ctx) =>
  ctx.addIssue(ZodCPFIssue)
);

const ZodCPFIssue = {
  code: "custom",
  params: {
    code: "invalid_cpf",
  },
} as const;

export type ZodCPFIssue = typeof ZodCPFIssue;

export const isZodCPFIssue = (
  issue: Omit<z.ZodIssue, "path" | "message">
): issue is ZodCPFIssue =>
  issue.code === "custom" &&
  (issue as z.ZodCustomIssue).params?.code === "invalid_cpf";

CNPJ
import { cnpj as cnpjValidator } from "cpf-cnpj-validator";
import { type z } from "zod";
import { type Nominal } from "~/types/nominal";
import { zodTransformer } from "../utils/zod-transformer";

export type CNPJ = Nominal<string, "cnpj">;

export function cnpj(cnpj: string): CNPJ {
  const stripped = cnpj.replace(/\D/g, "");

  const isValidCnpj = cnpjValidator.isValid(stripped);
  if (!isValidCnpj) {
    throw new Error("Invalid CNPJ");
  }
  return stripped as CNPJ;
}

export const isCnpj = (value: unknown): value is CNPJ =>
  typeof value === "string" && cnpjValidator.isValid(value.replace(/\D/g, ""));

export const zodCNPJTransformer = zodTransformer(cnpj, (_, ctx) =>
  ctx.addIssue(ZodCNPJIssue)
);

const ZodCNPJIssue = {
  code: "custom",
  params: {
    code: "invalid_cnpj",
  },
} as const;

export type ZodCNPJIssue = typeof ZodCNPJIssue;

export const isZodCNPJIssue = (
  issue: Omit<z.ZodIssue, "path" | "message">
): issue is ZodCNPJIssue =>
  issue.code === "custom" &&
  (issue as z.ZodCustomIssue).params?.code === "invalid_cnpj";

Telefone (formato E164 e brasileiro)
import { type z } from "zod";
// Lista dos DDDs válidos no Brasil
import { DDDs } from "~/utils/area-code";
import { zodTransformer } from "../utils/zod-transformer";
import { type Nominal } from "./nominal";

/**
 * @example +5511999999999
 * @example +551139999999
 * @see https://www.gov.br/anatel/pt-br/regulado/numeracao/plano-de-numeracao-brasileiro
 */
const isE164PhoneNumber = (phoneNumber: string): boolean => {
  if (!phoneNumber.startsWith("+55")) {
    return false;
  }
  const numberWithoutCountryCode = phoneNumber.slice(3);
  const areaCode = numberWithoutCountryCode.slice(0, 2);
  if (!isValidBrAreaCode(areaCode)) {
    return false;
  }
  const number = numberWithoutCountryCode.slice(2);
  if (!isValidBrNumber(number)) {
    return false;
  }
  return true;
};
/**
 * @example 11999999999
 * @example 1139999999
 */
const isBrPhoneNumber = (phoneNumber: string): boolean => {
  const isFormatValid = /^(\d{2})9?(\d{8})$/.test(phoneNumber);
  if (!isFormatValid) {
    return false;
  }
  const areaCode = phoneNumber.slice(0, 2);
  if (!isValidBrAreaCode(areaCode)) {
    return false;
  }
  const number = phoneNumber.slice(2);
  if (!isValidBrNumber(number)) {
    return false;
  }
  return true;
};

/**
 * @see https://www.gov.br/anatel/pt-br/regulado/numeracao/plano-de-numeracao-brasileiro
 *
 * Starts with 9 = mobile phone, must have 9 digits
 *
 * Starts with 2, 3, 4, 5, or 7 = landline/special mobile, must have 8 digits
 */
const isValidBrNumber = (number: string): boolean => {
  if (number.length > 0 && !isValidBrPrefix(number[0]!)) {
    return false;
  }
  const looksMobile = looksLikeBrMobile(number);
  if (looksMobile && number.length !== 9) {
    return false;
  }
  if (!looksMobile && number.length !== 8) {
    return false;
  }
  return true;
};

const BR_ALLOWED_PREFIXES = ["2", "3", "4", "5", "7", "9"];
const BR_ALLOWED_AREA_CODES = DDDs.map(ddd => ddd.areaCode.toString());

const isValidBrPrefix = (prefix: string): boolean =>
  BR_ALLOWED_PREFIXES.includes(prefix);
const isValidBrAreaCode = (areaCode: string): boolean =>
  BR_ALLOWED_AREA_CODES.includes(areaCode);
const looksLikeBrMobile = (number: string): boolean => number[0] === "9";

const cleanPhoneNumber = (phoneNumber: string): string =>
  phoneNumber.replace(/[^\d+]/g, "");

// remove all non-digit characters, except for the plus sign
export type PhoneNumberE164 = Nominal<string, "phone-number-e164">;

export function phoneNumberE164(
  phoneNumber: string | PhoneNumberBR
): PhoneNumberE164 {
  const cleanNumber = cleanPhoneNumber(phoneNumber);
  if (isE164PhoneNumber(cleanNumber)) {
    return cleanNumber as PhoneNumberE164;
  } else if (isBrPhoneNumber(cleanNumber)) {
    const areaCode = cleanNumber.slice(0, 2);
    const number = cleanNumber.slice(2);
    return `+55${areaCode}${number}` as PhoneNumberE164;
  } else {
    throw new Error("Invalid phone number");
  }
}

export type PhoneNumberBR = Nominal<string, "phone-number-br">;

export function phoneNumberBR(
  phoneNumber: string | PhoneNumberE164
): PhoneNumberBR {
  const cleanNumber = cleanPhoneNumber(phoneNumber);
  if (isBrPhoneNumber(cleanNumber)) {
    return cleanNumber as PhoneNumberBR;
  } else if (isE164PhoneNumber(cleanNumber)) {
    const numberWithoutCountryCode = cleanNumber.slice(3);
    return numberWithoutCountryCode as PhoneNumberBR;
  } else {
    throw new Error("Invalid phone number");
  }
}

export function toBrReadableFormat(
  phoneNumber: PhoneNumberE164 | PhoneNumberBR
) {
  const formattedNumber = phoneNumberBR(phoneNumber);
  const areaCode = formattedNumber.slice(0, 2);
  const firstPart = formattedNumber.slice(2, 7);
  const secondPart = formattedNumber.slice(7);
  return `(${areaCode}) ${firstPart}-${secondPart}`;
}

export const zodPhoneNumberE164Transformer = zodTransformer(
  phoneNumberE164,
  (_, ctx) => ctx.addIssue(ZodPhoneNumberIssue)
);

const ZodPhoneNumberIssue = {
  code: "custom",
  params: {
    code: "invalid_phone_number",
  },
} as const;

export type ZodPhoneNumberIssue = typeof ZodPhoneNumberIssue;

export const isZodPhoneNumberIssue = (
  issue: Omit<z.ZodIssue, "path" | "message">
): issue is ZodPhoneNumberIssue =>
  issue.code === "custom" &&
  (issue as z.ZodCustomIssue).params?.code === "invalid_phone_number";