Reasons to prefer explicit type annotations for objects
Consider this example where the myUser
variable has no type annotation:
ts
typeUser = {name : string;age : number;};constlogUserName = (user :User ) => {console .log (user .name );};constmyUser = {name : "bob",age : 123,};logUserName (myUser );
This builds with no type errors despite the fact that myUser
has no type annotation. This is because TypeScript is a structural type system. TypeScript infers the type of the myUser
variable and—when we try to pass that into logUserName
—TypeScript determines that it is structurally equal to the parameter of logUserName
which has type User
. This convenience helps to make TypeScript easier to adopt.
So, does it really matter that myUser
is not explicitly annotated with the User
type? At first glance, this seems fine. It looks like we still have type safety. If we add a new property to the User
type but we forget to update myUser
, we'll get a type error when we try to pass myUser
into logUserName
.
However, if we dig deeper we will find a number of reasons to prefer explicit type annotations for objects.
Language server features
Going back to our previous example, if you we try use TypeScript's rename feature to rename a property inside of the User
type, TypeScript is unable to update the name of the corresponding property in the myUser
value:
ts
typeUser = {fullName : string;age : number;};constlogUserName = (user :User ) => {console .log (user .fullName );};// ❌ `name` has not been renamed.constmyUser = {name : "bob",age : 123,};Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'User'. Property 'fullName' is missing in type '{ name: string; age: number; }' but required in type 'User'.2345Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'User'. Property 'fullName' is missing in type '{ name: string; age: number; }' but required in type 'User'.logUserName (); myUser
This happens because TypeScript does not understand that the object value myUser
relates to the User
type, because we haven't told it (i.e. the object is not annotated).
As you can see above, we do get a type error when we try to pass myUser
into logUserName
, so we're at least reminded to update myUser
. However, renaming is a frequent operation during refactoring, so it's preferable if renames can be fully automated, especially in large code bases where the object type may be used in many places.
It is for the same reason that other language server features like "go to definition" and "find references" on object properties will also not work reliably. This makes it harder to navigate around the code, harming readability and the developer experience when debugging.
Bugs caused by excess properties
Previously we observed that changes to the type resulted in a type error, which serves as a useful reminder that we need to update our values to match the new type. However, there are some cases where TypeScript is not able to do this, meaning it's possible for bugs to slip in. Specifically, this happens because excess properties are allowed for objects without type annotations.
Below are some examples of different scenarios where you might encounter this.
Example with optional properties
Consider this example where the myConfig
variable has no type annotation:
ts
typeConfig = {foo ?: string;bar : string;};constupdateConfig = (config :Config ) => {};constmyConfig = {foo : "yay",bar : "woo",};updateConfig (myConfig );
Now imagine that we decide to rename the optional foo
property in the Config
type using TypeScript's rename feature. Watch what happens:
ts
typeConfig = {fooNEW ?: string;bar : string;};constupdateConfig = (config :Config ) => {};// ❌ `foo` has not been renamed.// ❌ No type error to alert us to the problem.constmyConfig = {foo : "yay",bar : "woo",};updateConfig (myConfig );
We probably have a bug in our code now—myConfig
is still using the old property name (foo
)—and there's no type error to alert us.
Example with spread
Consider this example where the newState
variable has no type annotation:
ts
typeState = {foo : string;bar : string;};declare conststate :State ;constnewState = {...state ,foo : "abc",};
Now imagine that we decide to rename the foo
property in the State
type using TypeScript's rename feature. Watch what happens:
ts
typeState = {fooNEW : string;bar : string;};declare conststate :State ;constnewState = {...state ,// ❌ `foo` has not been renamed.// ❌ No type error to alert us to the problem.foo : "abc",};
We probably have a bug in our code now—newState
is still using the old property name (foo
)—and there's no type error to alert us.
A real world example
Here's a reduced test case of a real bug we encountered in production at Unsplash:
ts
typeState = {foo : string;bar : string;};typeAction = {tag : "UpdateFoo" } | {tag : "UpdateBar" };declare constmatchAction : <T >(action :Action ,matchers :Record <Action ["tag"], () =>T >) =>T ;constreducer = (state :State ,action :Action ):State =>matchAction (action , {UpdateFoo : () => ({...state ,foo : "abc",}),UpdateBar : () => ({...state ,bar : "abc",}),});
Similar to the previous example, if we rename the foo
property in the State
type, the objects inside reducer
will not be updated.
This is despite the fact that reducer
has a return type. The reason the return type isn't sufficient is because it doesn't flow through to the objects returned by UpdateFoo
and UpdateBar
.
Lint rule
To enforce type annotations for all objects, we wrote a lint rule for use at Unsplash: require-object-type-annotations
. This lint rule uses type information to determine whether a given ObjectExpression
node has a contextual type. We've been using this lint rule at Unsplash to great success. It's not perfect—there are some false negatives (see the skipped tests)—but it is able to catch the majority of instances.
I attempted to contribute this lint rule to @typescript-eslint
(WIP PR) but unfortunately it got stuck due to concerns about performance caused by the rule's usage of type information. There's a suggestion that it might be possible to implement this lint rule without type information, but I worry this would have too many false negatives. I'm hopeful that someone can prove me wrong. In any case, whilst this lint rule is expensive, it hasn't caused any problems for us at Unsplash (in a very large codebase). It's a cost we're willing to pay.
Objects without types
In some cases we don't want to define a type for an object, because the object is very briefly used or because we want to derive the type from the object value. For example:
ts
constroutes = {Home : "/home",About : "/about",UserProfile : "/@:username",};consthandleRequest = (pathPattern : string) => {};handleRequest (routes .Home );
In this example, language server features (like "go to definition", "find references" and "rename") still work on the object properties.
To accommodate for this we can conditionally disable the lint rule. In our experience at Unsplash, this represents a small minority of objects. Most of the time we are defining types upfront so this is not much of an issue, but your milage may vary.