typescript - Validar chaves excessivas no valor retornado de uma função
Digamos que eu esteja fazendo isso:
type Keys = 'a' | 'b' | 'c'
type Rec = { [K in Keys]?: number }
let rec: Rec = { a: 1, d: 4 }
Isso resulta em:
Type '{ a: number; d: number; }' is not assignable to type 'Rec'.
Object literal may only specify known properties, and 'd' does not exist in type 'Rec'
Então, ele não permite nenhuma chave extra em um objeto.
Mas se eu fizer o seguinte:
type Func = () => Rec
const fn: Func = () => ({ a: 1, d: 4 })
TS está totalmente ok com isso, apesar de a função dada definitivamente não retornar um tipo Rec.
E, ao mesmo tempo, usar const fn: Func =() => ({ a: false, d: 4 }) dá
Type 'boolean' is not assignable to type 'number | undefined'.(2322)
input.tsx(63, 12): The expected type comes from property 'a' which is declared here on type 'Rec'
Então, na verdade ele valida um valor retornado. Mas de alguma forma não se importa com chaves extras.
Demonstração
Por que isso acontece, e existe alguma maneira de não permitir chaves extras no valor retornado neste caso?
Resposta #1
Observe que {a: 1, d: 4} é do tipo Rec. Tipos de objeto em TypeScript geralmente permitem propriedades extras e não são "exatos" como solicitado em microsoft/TypeScript#12936. Existem bons motivos para isso relacionados a subtipagem e atribuibilidade. Por exemplo:
class Foo {a: string = ""}
class Bar extends Foo {b: number = 123}
console.log(new Bar() instanceof Foo); // true
Observe que todo Bar é um Foo, o que significa que você não pode dizer "todos os objetos Foo apenas têm uma propriedade a" sem impedir a herança e a extensão de classes ou interfaces. E já que interface funciona da mesma forma, e já que o sistema de tipos do TypeScript é estrutural e não nominal, você nem precisa declarar um tipo Bar para que ele exista:
interface Foo2 {a: string};
// interface Bar2 extends Foo2 {b: number};
const bar2 = {a: "", b: 123 };
const foo2: Foo2 = bar2; // okay
Então, para o bem ou para o mal, estamos presos a um sistema de tipos no qual propriedades extras não quebram a compatibilidade de tipos.
Claro, isso pode ser uma fonte de erros. Portanto, no caso em que você está explicitamente atribuindo um novo literal de objeto a um lugar que espera um tipo de objeto específico, existem verificações de propriedades excessivas que se comportam como se o tipo fosse exato. Essas verificações acontecem apenas em circunstâncias particulares, como no seu primeiro exemplo:
let rec: Rec = { a: 1, d: 4 }; // excess property warning
Mas os valores de retorno de funções atualmente não são uma dessas circunstâncias. O tipo do valor de retorno é ampliado antes que quaisquer verificações de propriedades excedentes possam ocorrer. Existe uma questão bastante antiga no GitHub, microsoft/TypeScript#241, que sugere que isso deveria ser alterado para que os valores de retorno de funções não sejam ampliados dessa maneira, e há até uma implementação de uma possível correção em microsoft/TypeScript#40311, mas ela foi fechada, então pode nunca ser incluída na linguagem.
Não existem formas perfeitas de suprimir propriedades em excesso de maneira geral. Meu conselho é apenas aceitar que os objetos podem ter chaves extras e garantir que qualquer código que você escreva não quebre nesse caso. Você pode fazer coisas que desencorajam propriedades em excesso, como estas:
// annotate return type explicitly
const fn2: Func = (): Rec => ({ a: 1, d: 4 }) // excess property warning
// use a generic type that gets mad about excess properties
const asFunc = <T extends Rec & Record<Exclude<keyof T, keyof Rec>, never>>(
cb: () => T
): Func => cb;
const fn3 = asFunc(() => ({ a: 1, d: 4 })); // error! number is not never
Mas eles são mais complicados e facilmente quebráveis, já que nada de absolutamente nada vai impedir você de fazer isso, não importa o quanto você tente proteger seu tipo Func:
const someBadFunc = () => ({ a: 1, d: 4 });
const cannotPreventThis: Rec = someBadFunc();
Escrever código que antecipa propriedades extras geralmente envolve manter um array de chaves conhecidas. Então, não faça isso:
function extraKeysBad(rec: Rec) {
for (const k in rec) {
const v = rec[k as keyof Rec]; // bad assumption that k is keyof Rec
console.log(k + ": " + v?.toFixed(2))
}
}
const extraKeys = {a: 1, b: 2, d: "four"};
extraKeysBad(extraKeys); // a: 1.00, b: 2.00, RUNTIME ERROR! v.toFixed not a function
Faça isto em vez disso:
function extraKeysOkay(rec: Rec) {
for (const k of ["a", "b", "c"] as const) {
const v = rec[k];
console.log(k + ": " + v?.toFixed(2))
}
}
extraKeysOkay(extraKeys); // a: 1.00, b: 2.00, c: undefined
Link do playground para o código
