Explore os tipos exatos do TypeScript para correspondência rigorosa do formato de objeto, evitando propriedades inesperadas e garantindo a robustez do código. Aprenda aplicações práticas e melhores práticas.
Tipos Exatos em TypeScript: Correspondência Rígida de Formato de Objeto para Código Robusto
TypeScript, um superconjunto de JavaScript, traz tipagem estática para o mundo dinâmico do desenvolvimento web. Embora o TypeScript ofereça vantagens significativas em termos de segurança de tipos e capacidade de manutenção do código, seu sistema de tipagem estrutural pode, às vezes, levar a um comportamento inesperado. É aqui que o conceito de "tipos exatos" entra em jogo. Embora o TypeScript não tenha um recurso embutido explicitamente chamado "tipos exatos", podemos alcançar um comportamento semelhante por meio de uma combinação de recursos e técnicas do TypeScript. Esta publicação do blog irá aprofundar-se em como impor uma correspondência de formato de objeto mais rigorosa no TypeScript para melhorar a robustez do código e evitar erros comuns.
Entendendo a Tipagem Estrutural do TypeScript
O TypeScript emprega tipagem estrutural (também conhecida como pato-tipagem), o que significa que a compatibilidade de tipos é determinada pelos membros dos tipos, em vez de seus nomes declarados. Se um objeto tiver todas as propriedades exigidas por um tipo, ele será considerado compatível com esse tipo, independentemente de ter propriedades adicionais.
Por exemplo:
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
printPoint(myPoint); // Isso funciona bem, embora myPoint tenha a propriedade 'z'
Nesse cenário, o TypeScript permite que `myPoint` seja passado para `printPoint` porque ele contém as propriedades `x` e `y` necessárias, mesmo que tenha uma propriedade `z` extra. Embora essa flexibilidade possa ser conveniente, ela também pode levar a erros sutis se você passar inadvertidamente objetos com propriedades inesperadas.
O Problema com Propriedades em Excesso
A indulgência da tipagem estrutural pode, às vezes, mascarar erros. Considere uma função que espera um objeto de configuração:
interface Config {
apiUrl: string;
timeout: number;
}
function setup(config: Config) {
console.log(`API URL: ${config.apiUrl}`);
console.log(`Timeout: ${config.timeout}`);
}
const myConfig = { apiUrl: "https://api.example.com", timeout: 5000, typo: true };
setup(myConfig); // TypeScript não reclama aqui!
console.log(myConfig.typo); //imprime true. A propriedade extra existe silenciosamente
Neste exemplo, `myConfig` tem uma propriedade extra `typo`. O TypeScript não levanta um erro porque `myConfig` ainda satisfaz a interface `Config`. No entanto, o erro de digitação nunca é detectado e o aplicativo pode não se comportar conforme o esperado se o erro de digitação fosse pretendido ser `typoo`. Essas questões aparentemente insignificantes podem se transformar em grandes dores de cabeça ao depurar aplicativos complexos. Uma propriedade ausente ou com erros de digitação pode ser especialmente difícil de detectar ao lidar com objetos aninhados dentro de objetos.
Abordagens para Impor Tipos Exatos no TypeScript
Embora os verdadeiros "tipos exatos" não estejam diretamente disponíveis no TypeScript, aqui estão várias técnicas para obter resultados semelhantes e impor uma correspondência de formato de objeto mais rigorosa:
1. Usando Asserções de Tipo com `Omit`
O tipo de utilidade `Omit` permite criar um novo tipo excluindo certas propriedades de um tipo existente. Combinado com uma asserção de tipo, isso pode ajudar a evitar propriedades em excesso.
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
// Crie um tipo que inclua apenas as propriedades de Point
const exactPoint: Point = myPoint as Omit & Point;
// Erro: Type '{ x: number; y: number; z: number; }' is not assignable to type 'Point'.
// Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
//Fix
const myPointCorrect = { x: 10, y: 20 };
const exactPointCorrect: Point = myPointCorrect as Omit & Point;
printPoint(exactPointCorrect);
Essa abordagem lança um erro se `myPoint` tiver propriedades que não estão definidas na interface `Point`.
Explicação: `Omit
2. Usando uma Função para Criar Objetos
Você pode criar uma função de fábrica que aceita apenas as propriedades definidas na interface. Essa abordagem fornece uma forte verificação de tipo no ponto de criação do objeto.
interface Config {
apiUrl: string;
timeout: number;
}
function createConfig(config: Config): Config {
return {
apiUrl: config.apiUrl,
timeout: config.timeout,
};
}
const myConfig = createConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
//This will not compile:
//const myConfigError = createConfig({ apiUrl: "https://api.example.com", timeout: 5000, typo: true });
//Argument of type '{ apiUrl: string; timeout: number; typo: true; }' is not assignable to parameter of type 'Config'.
// Object literal may only specify known properties, and 'typo' does not exist in type 'Config'.
Ao retornar um objeto construído apenas com as propriedades definidas na interface `Config`, você garante que nenhuma propriedade extra possa entrar. Isso torna mais seguro criar a configuração.
3. Usando Guardas de Tipo
As guardas de tipo são funções que restringem o tipo de uma variável dentro de um escopo específico. Embora não impeçam diretamente propriedades em excesso, elas podem ajudá-lo a verificá-las explicitamente e tomar as medidas apropriadas.
interface User {
id: number;
name: string;
}
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj && typeof obj.id === 'number' &&
'name' in obj && typeof obj.name === 'string' &&
Object.keys(obj).length === 2 //check for number of keys. Note: brittle and depends on User's exact key count.
);
}
const potentialUser1 = { id: 123, name: "Alice" };
const potentialUser2 = { id: 456, name: "Bob", extra: true };
if (isUser(potentialUser1)) {
console.log("Valid User:", potentialUser1.name);
} else {
console.log("Invalid User");
}
if (isUser(potentialUser2)) {
console.log("Valid User:", potentialUser2.name); //Will not hit here
} else {
console.log("Invalid User");
}
Neste exemplo, a guarda de tipo `isUser` verifica não apenas a presença de propriedades exigidas, mas também seus tipos e o número *exato* de propriedades. Essa abordagem é mais explícita e permite que você lide com objetos inválidos com elegância. No entanto, a verificação do número de propriedades é frágil. Sempre que `User` ganha/perde propriedades, a verificação deve ser atualizada.
4. Alavancando `Readonly` e `as const`
Enquanto `Readonly` impede a modificação de propriedades existentes, e `as const` cria uma tupla ou objeto somente leitura onde todas as propriedades são profundamente somente leitura e têm tipos literais, eles podem ser usados para criar uma definição mais rigorosa e verificação de tipo quando combinados com outros métodos. Embora, nenhum impeça propriedades em excesso por conta própria.
interface Options {
width: number;
height: number;
}
//Create the Readonly type
type ReadonlyOptions = Readonly;
const options: ReadonlyOptions = { width: 100, height: 200 };
//options.width = 300; //error: Cannot assign to 'width' because it is a read-only property.
//Using as const
const config = { api_url: "https://example.com", timeout: 3000 } as const;
//config.timeout = 5000; //error: Cannot assign to 'timeout' because it is a read-only property.
//However, excess properties are still allowed:
const invalidOptions: ReadonlyOptions = { width: 100, height: 200, depth: 300 }; //no error. Still allows excess properties.
interface StrictOptions {
readonly width: number;
readonly height: number;
}
//This will now error:
//const invalidStrictOptions: StrictOptions = { width: 100, height: 200, depth: 300 };
//Type '{ width: number; height: number; depth: number; }' is not assignable to type 'StrictOptions'.
// Object literal may only specify known properties, and 'depth' does not exist in type 'StrictOptions'.
Isso melhora a imutabilidade, mas só impede a mutação, não a existência de propriedades extras. Combinado com `Omit`, ou a abordagem de função, torna-se mais eficaz.
5. Usando Bibliotecas (por exemplo, Zod, io-ts)
Bibliotecas como Zod e io-ts oferecem poderosos recursos de validação de tipo em tempo de execução e definição de esquema. Essas bibliotecas permitem definir esquemas que descrevem precisamente a forma esperada de seus dados, incluindo a prevenção de propriedades em excesso. Embora adicionem uma dependência em tempo de execução, elas oferecem uma solução muito robusta e flexível.
Exemplo com Zod:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer;
const validUser = { id: 1, name: "John" };
const invalidUser = { id: 2, name: "Jane", extra: true };
const parsedValidUser = UserSchema.parse(validUser);
console.log("Parsed Valid User:", parsedValidUser);
try {
const parsedInvalidUser = UserSchema.parse(invalidUser);
console.log("Parsed Invalid User:", parsedInvalidUser); // This won't be reached
} catch (error) {
console.error("Validation Error:", error.errors);
}
O método `parse` do Zod lançará um erro se a entrada não estiver em conformidade com o esquema, efetivamente impedindo propriedades em excesso. Isso fornece validação em tempo de execução e também gera tipos TypeScript a partir do esquema, garantindo a consistência entre suas definições de tipo e a lógica de validação em tempo de execução.
Melhores Práticas para Impor Tipos Exatos
Aqui estão algumas melhores práticas a serem consideradas ao impor uma correspondência de formato de objeto mais rigorosa no TypeScript:
- Escolha a técnica certa: A melhor abordagem depende de suas necessidades específicas e requisitos do projeto. Para casos simples, as asserções de tipo com `Omit` ou funções de fábrica podem ser suficientes. Para cenários mais complexos ou quando a validação em tempo de execução é necessária, considere usar bibliotecas como Zod ou io-ts.
- Seja consistente: Aplique a abordagem escolhida de forma consistente em todo o seu código base para manter um nível uniforme de segurança de tipo.
- Documente seus tipos: Documente claramente suas interfaces e tipos para comunicar a forma esperada de seus dados a outros desenvolvedores.
- Teste seu código: Escreva testes de unidade para verificar se suas restrições de tipo estão funcionando como esperado e se seu código lida com dados inválidos com elegância.
- Considere as compensações: Impor uma correspondência de formato de objeto mais rigorosa pode tornar seu código mais robusto, mas também pode aumentar o tempo de desenvolvimento. Pese os benefícios contra os custos e escolha a abordagem que fizer mais sentido para seu projeto.
- Adoção gradual: Se você estiver trabalhando em um grande código base existente, considere adotar essas técnicas gradualmente, começando pelas partes mais críticas de seu aplicativo.
- Prefira interfaces a aliases de tipo ao definir formatos de objeto: Interfaces são geralmente preferidas porque suportam a mesclagem de declarações, o que pode ser útil para estender tipos em arquivos diferentes.
Exemplos do Mundo Real
Vamos analisar alguns cenários do mundo real em que os tipos exatos podem ser benéficos:
- Cargas de solicitação de API: Ao enviar dados para uma API, é crucial garantir que a carga esteja em conformidade com o esquema esperado. Impor tipos exatos pode evitar erros causados pelo envio de propriedades inesperadas. Por exemplo, muitas APIs de processamento de pagamentos são extremamente sensíveis a dados inesperados.
- Arquivos de configuração: Os arquivos de configuração costumam conter um grande número de propriedades, e erros de digitação podem ser comuns. O uso de tipos exatos pode ajudar a detectar esses erros de digitação desde o início. Se você estiver configurando locais de servidor em uma implantação na nuvem, um erro de digitação em uma configuração de local (por exemplo, eu-oeste-1 vs. eu-wet-1) se tornará extremamente difícil de depurar se não for detectado antecipadamente.
- Pipelines de transformação de dados: Ao transformar dados de um formato para outro, é importante garantir que os dados de saída estejam em conformidade com o esquema esperado.
- Filas de mensagens: Ao enviar mensagens por meio de uma fila de mensagens, é importante garantir que a carga da mensagem seja válida e contenha as propriedades corretas.
Exemplo: Configuração de Internacionalização (i18n)
Imagine gerenciar traduções para um aplicativo multilíngue. Você pode ter um objeto de configuração como este:
interface Translation {
greeting: string;
farewell: string;
}
interface I18nConfig {
locale: string;
translations: Translation;
}
const englishConfig: I18nConfig = {
locale: "en-US",
translations: {
greeting: "Hello",
farewell: "Goodbye"
}
};
//This will be an issue, as an excess property exists, silently introducing a bug.
const spanishConfig: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós",
typo: "unintentional translation"
}
};
//Solution: Using Omit
const spanishConfigCorrect: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós"
} as Omit & Translation
};
Sem tipos exatos, um erro de digitação em uma chave de tradução (como adicionar um campo `typo`) poderia passar despercebido, levando a traduções ausentes na interface do usuário. Ao impor uma correspondência de formato de objeto mais rigorosa, você pode detectar esses erros durante o desenvolvimento e impedir que eles cheguem à produção.
Conclusão
Embora o TypeScript não tenha "tipos exatos" embutidos, você pode obter resultados semelhantes usando uma combinação de recursos e técnicas do TypeScript, como asserções de tipo com `Omit`, funções de fábrica, guardas de tipo, `Readonly`, `as const` e bibliotecas externas como Zod e io-ts. Ao impor uma correspondência de formato de objeto mais rigorosa, você pode melhorar a robustez de seu código, evitar erros comuns e tornar seus aplicativos mais confiáveis. Lembre-se de escolher a abordagem que melhor se adapta às suas necessidades e seja consistente em aplicá-la em todo o seu código base. Ao considerar cuidadosamente essas abordagens, você pode ter maior controle sobre os tipos de seu aplicativo e aumentar a capacidade de manutenção a longo prazo.