“The first thing that many new TypeScript developers do when they convert a codebase from JavaScript is fill it with type annotations. TypeScript is about types, after all! But in TypeScript many annotations are unnecessary. Declaring types for all your variables is counterproductive and is considered poor style.” source: https://effectivetypescript.com/ 2020/04/28/avoid-inferable/.
To better understand that we have to figure out the distinction of two possibilities of adding type annotations in TypeScript: explicit and implicit.
In this article, I am going try to face with this nitty-gritty type annotations and point up when explicitly type is desirable and when you should rely on type inference with the same type of safety.
Explicit vs Implicit
At the beginning I’ve mentioned that in TypeScript there’s two types of annotations:
- Explicit – generally it’s manifestly adding type to our codebase. We have to exactly to know what kind of type the value is eg:
- Implicit – means that the type is inferred by TypeScript type inference system which takes responsibility away from us of writing the types:
In the example above when hovering over the variable, the IDE prompts the inferred type and identifies its type as “Honda” which is more precise than string.
Explicit and Implicit – case study
Knowing the differences, let’s go through some use cases of adding explicit type annotation, keeping in mind that using it everywhere can make some of them redundant, resulting in a verbose codebase.
But on the other hand, adding explicit type annotation is sometimes required, especially when TypeScript doesn’t have enough context to infer types eg. Function parameters:
Further with the compiler flag –noImplicitAny in .tsconfig file that code produces an error:
From this rule there’s one exception – with default parameter TypeScript can infer it’s type:
There are also situations when adding explicit type annotation has more benefits even if TypeScript can infer the type.
One of the scenarios is when we are informed about an error in a place where we provided a variable with the wrong type, rather than in a place where is the type signature:
In the above example, TypeScript informs us about an error in a place where we used a variable, compared to where error really occurs because we have provided a wrong type.
We can fix that and get a better understanding about the place of occurrence of an error with adding explicit type annotation to car1 variable:
There is also one notable thing about type inference and how explicit types can help with occurred errors.
When we accept a union type in function parameters and we provide a parameter inline, there is no error:
But whenever you can break it reassigning a variable value with let keyword and then pass it as a function parameter:
This is because Typescript infers a type in the time of assignment and infers this as a string. Basically, when the value of a variable can change, the type of it does not.
One of the possible ways to fix it is to create a union type and explicitly type a variable:
Previously I’ve mentioned that sometimes adding an explicit type annotation can make your codebase verbose.
One of the examples of that is with typing destructured object:
The above code is repetitive and we can achieve the same results when types are inferred and with that code is more readable:
Also, adding explicit type annotation to variables inside of the function body is redundant, unnecessary and creates an additional amount of work in a situation when, let’s say, at some point, car id will be changed to e.g number:
It’s worth to keep it in mind that sometimes “less is more”. You can save extra work, time and effort needed to refactor code by providing an explicitly well-typed function signature (parameters and return type) and by omitting type annotation inside of the function body in places where TypeScript can infer it.
ESLint
Now let’s talk about tools called “linters”. Their job is to statically analyze our code and inform about occurring errors. ESlint is probably the most popular one.
There is a tremendous number of different rules and options which we can follow or simply install ready-to-go packages. For support TypeScript, there is a typescript-eslint package that enables this and from this one, I’ll show two of the rules which help me to work with an explicit type annotation.
The first one is:
@TYPESCRIPT-ESLINT/TYPEDEF
This one checks that we provide an explicit type definition and the documentation also mentioned what I’ve talked earlier:
“TypeScript cannot always infer types for all places in code. Some locations require type annotations for their types to be inferred”
The documentation also mention about —noImplicitAny:
“Instead of enabling typedef, it is generally recommended to use the –noImplicitAny and/or –strictPropertyInitialization compiler options to enforce type“annotations only when useful.”
But in my opinion, it is worth to consider installing this one and enabling it with the following options:
- “arrowParameter” – “Whether to enforce type annotations for parameters of arrow functions.”:
It means that you need to provide an explicit type annotation to arguments in whatever arrow functions:
- ”parameter” – this option is very similar, it enforces you to type annotations for parameters in class methods and normal function parameters:
To summarize, these two options are useful whenever you want to provide type annotation for “in” data.
@TYPESCRIPT-ESLINT/NO-INFERRABLE-TYPES
The second ESlint package finds unnecessary explicit type annotation places and throws an error where types are trivial and it can be successfully inferred:
In summary, if you want to have control over the incoming data types and not make redundant type annotation, these two rules are quite useful for that.
Return type
Type inference also works well with the “out” data types of function.
Yet adding explicit return type gives you one more privilege – it allows you to use your defined name type declaration and with that if our “in/out” data are same type, they’re compatible.
And here’s a couple of possibilities of type annotation return value:
With pre-defined signature:
Without used predefined type signature our code is verbose similar to destructuring example:
Typescript also provides a nice utility if you need to operate on a returned type – ReturnType
Another possibility to declare return type is a type whole function signature:
Using that you can keep your type declarations in a separate file, rather than in place where a function is defined – keeping your codebase clean and concise.
Explicit pros and cons:
We now know the difference between explicit and implicit type annotation and their usage scenarios. Let’s go through some pros of explicit type annotation first:
- Readability – it’s easier to understand what particular method/function does, when its “in/out” data have explicit type annotation. Also, we can predict the first look with which kind of data we’ll be working with.
- Comprehensibility – when adding explicit types, you have to think more about implementation logic, because you should know what comes in and what comes out before you implement it. This approach is very similar to the TDD approach. Typing your signature first, before implementing a logic of the function.
- Consistency – annotate type for parameters and return the value it’s like a contract. It makes code less prone to errors and helps to think in a pure functions approach, when given the same input, always returning the same output without any side effects.
- Named type – last but not least. Adding an explicit return type allows you to stay associated with your types and interfaces.
And now some cons:
- Trust – you have to keep your trust that the exported type or interface by someone else or yours are with the correct type of signature.
- Time-consuming – adding too much explicit type annotations consume more time and effort, especially during code refactor or when type has changed.
- Inflexible – depending only on explicit type and not using the benefits of type inference makes your codebase very rigid, similar to other statically typed languages, resulting in the loss of the main advantages of JavaScript dynamic heritage.
Summary
Adding TypeScript to your codebase always generates an extra cost, given the fact that JavaScript is not a typed language.
However, in the long-term adding explicit type annotation into sensitive places is a desirable thing, and basing on inferred type can reduce the amount of code when the types are about to change which results in cost reduction, and a lot of saved time and effort during future code refactors.
“Ideal TypeScript code includes type annotations for function/method signatures but not for the local variables created in their bodies. This keeps noise to a minimum and lets readers focus on the implementation logic”. source: Effective TypeScript: 62 Specific Ways to Improve Your TypeScript Dan Vanderkam. Str 84. Chapter 3: Type Inference.
In the end, it is all about finding a balance between explicit and implicit types.
Share your experience with playing with both type annotations in TypeScript in the comments!