TypeScript: Excess Property Checks
Last Updated on
When is it helpful?
Let's say that we have a Dog
interface
interface Dog {
legs?: number;
}
and a function that accepts Dog
as an argument:
function describeDog(dog: Dog) {
console.log(`dog has: ${Object.keys(dog)}`);
}
We are supposed to use it like this:
describeDog({ legs: 4 });
Now, let's try to do something stupid:
describeDog({ legs: 2, wings: 2 });
This obviously doesn't work, because dogs don't have wings, we get an error:
Argument of type '{ legs: number; wings: number; }' is not assignable to parameter of type 'Dog'.
Object literal may only specify known properties, and 'wings' does not exist in type 'Dog'.
Makes total sense, and that's why TypeScript is amazing (yay!)
What to avoid?
Of course, we can shoot ourselves in a foot by creating a flying dog:
describeDog({ legs: 2, wings: 2 } as Dog);
but thankfully we have @typescript-eslint/consistent-type-assertions rule to prevent this, and you should use it in your .eslintrc
:
{
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/consistent-type-assertions": [
"error",
{
"assertionStyle": "never"
}
]
}
}
Also, you can Suppress Excess Property Errors, make sure that you don't do that.
When doesn't it work?
However, if we do something as seemingly innocent as moving an object into a variable:
const unknownCreature = {
legs: 2,
wings: 2,
};
describeDog(unknownCreature);
Guess what? All of a sudden, TS forgets about excess properties and says that "dog has: legs,wings" which obviously isn't right because a dog isn't supposed to have wings...
To prevent this, we can explicitly annotate variable type upon declaration:
const unknownCreature: Dog = {
legs: 2,
wings: 2,
};
Now this will throw an error as expected
Type '{ legs: number; wings: number; }' is not assignable to type 'Dog'.
Object literal may only specify known properties, and 'wings' does not exist in type 'Dog'.
And we're saved, yay!
How to make it always work?
What if we forget to annotate variable type explicitly?
Don't worry, there's a @typescript-eslint/typedef rule, which has variable-declaration
option that can help us:
{
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/typedef": [
"error",
{
"variableDeclaration": true
}
]
}
}
So now if we try to build a beast:
const unknownCreature = {
legs: 2,
wings: 2,
};
it won't let us:
Expected unknownCreature to have a type annotation.
But there are downsides:
Now you can't use @typescript-eslint/no-inferrable-types, because otherwise
const name = 'Max';
produces
Expected name to have a type annotation
bytypedef
rule.And if you fix it:
const name: string = 'Max';
then
no-inferrable-types
will sayType string trivially inferred from a string literal, remove type annotation
, creating a loop.We can break the loop by disabling
no-inferrable-types
rule, but then our code may look a bit too explicit to JS developers, however, I think that C++ developers will not find anything wrong with explicitly annotating primitive variable types.Using 3rd-party libraries or other complex types becomes much harder. For example, let's say that we have Vuex mutations object and we want to mock one of them during the test, and then restore it to the original implementation:
export const mutations: MutationTree<typeof state> = { ['ADD_COLLECTION']() { // do something }, }; const originalImplementation = mutations['ADD_COLLECTION']; // save mutations['ADD_COLLECTION'] = jest.fn(); // override/mock // perform some tests mutations['ADD_COLLECTION'] = originalImplementation; // restore - fails with eslint error
One would expect this to work, but it doesn't:
Type 'Mutation<unknown> | undefined' is not assignable to type 'Mutation<unknown>'. Type 'undefined' is not assignable to type 'Mutation<unknown>'.
Why? Because this is how
MutationTree
is defined in Vuex:export interface MutationTree<S> { [key: string]: Mutation<S>; }
This means, that even though we're 100% sure that the
mutations
object hasADD_COLLECTION
key, the type definition from Vuex doesn't let TypeScript infer that, and uses a more "easy" type[key: string]
, making TypeScript assume thatmutation
object keys can be any string.Here's how we can solve this:
const asMutationTree = <T>(mutation: { [K in keyof T]: Mutation<typeof state>; }) => mutation; export const mutations = asMutationTree({ ['ADD_COLLECTION']() { // do something }, });
(thanks to SO answer)
Now TS knows that
mutations
object hasADD_COLLECTION
key, all good.If we add
typedef
rule withvariable-declaration
, we're gonna have to annotatemutations
object manually:export const mutations: { ADD_COLLECTION: Mutation<typeof state>; } = asMutationTree({ ['ADD_COLLECTION']() { // do something }, });
Now imagine that this is a real-world app, with hundreds of mutations, and you have to duplicate the key in two places every single time: in type annotation, and the property key declaration:
export const mutations: { ADD_COLLECTION: Mutation<typeof state>; RENAME_COLLECTION: Mutation<typeof state>; DELETE_COLLECTION: Mutation<typeof state>; // and so on } = asMutationTree({ ['ADD_COLLECTION']() {}, ['RENAME_COLLECTION']() {}, ['DELETE_COLLECTION']() {}, // and so on });
This will get repetitive pretty quickly.
Also, we can see that all of them have
Mutation<typeof state>
value type, which we also have to repeat for every single mutation.Some might find it a bit too verbose, especially when you're working on a fresh project and constantly refactoring things, instead of just renaming mutation in one place, you'll have to rename it in two places now, etc. Ultimately slowing down the development.
So I'd only recommend using this in stable stage or legacy projects; and in projects where minor mistakes can have a critical impact, like finance.
Summary
- TypeScript is awesome
- Use @typescript-eslint/consistent-type-assertions
- Maybe use @typescript-eslint/typedef with
variable-declaration
Please, share (Tweet) this article if you found it helpful or entertaining!
Read more about Excess Property Checks (ignore that page is deprecated, this particular part still applies at the time of writing)