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:
- Abre a possibilidade de ser qualquer string, inclusive
""
,"@"
. - Lidar com tipos
string
não me dá contexto sobre o conteúdo. Só tenho contexto se tiver acesso ao nome da variávelemail
, que pode ter vários nomes dentro da aplicação. - 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";