TypeScript has a robust inference system, which automatically assigns the best possible data type for values. It works very well for variables, objects, arrays and more, so you do not need to think about writing annotations for them. But for functions, inference evaluates only a return value and assign best possible type candidate for it. That means function arguments are not evaluated, and we need to do it by writing annotations manually.
Function annotations can be written in couple of ways. First you can annotate a variable as a function type (which is more variable annotation then a function):
const myFunction: (name: string) => void = (name) => console.log(name);
and a regular function annotation, which I will focus on:
const myFunction = (name: string): void => console.log(name);
Don't trust inference
Coming back to the inference system, I want to show why we should not rely on it for functions. Take the following picture, for example, where a function receives two strings as arguments, first and second name, and returns a full name. I will not add annotations on purpose, see how the inference evaluates the functions. Firstly, TypeScript cannot determine which types used for function arguments, so they are marked as any
type, which we try to avoid:
But the function return statement was evaluated and data type assigned correctly:
Furthermore, if you accidentally forgot a return statement, then will get a void
type and no errors:
That is why to trust inference for functions is not an ideal and very error-prone way.
Writing annotations
By writing annotations for arguments and return values, we help TypeScript to prevent errors and notify us early if we try to use different types.
The syntax for a different kind of functions is pretty much the same: (arg: Type): Type
. Annotations for arguments go into the parentheses, and return type follows right after them.
Arrow functions:
Taken the same example from above, we would annotate it this way:
const getFullName = (firstName: string, secondName: string): string => {
return firstName + ' ' + secondName;
}
Now if you accidentally forget a return statement, you will be notified that function should return, and it must be a string. Same goes for both arguments.
Function declarations:
function multiply(x: number, y: number): number {
return x * y;
}
Function expressions:
const multiply = function(x: number, y: number): number {
return x * y;
}
Destructuring function arguments
To annotate an object as function argument you would follow the same object literal annotation, only in a function parentheses:
const logPerson = (person: { name: string; age: number }) => {
console.log('name:', person.name, 'age:', person.age);
}
And if you prefer argument destructuring, it goes with the same syntax:
const logPerson = ({ name, age }: { name: string; age: number }) => {
console.log('name:', name, 'age:', age);
}
Also, I should mention a function return type never
, which is used for sporadic cases. It makes sure the function will never reach its end. A use case for it if an error will be thrown inside the function:
function neverReachAnEnd(): never {
throw new Error();
}
Conclusion
While the inference system works very well for other data types, do not trust it for functions. Write annotations manually to help TypeScript understand what we intend to pass for function arguments and what is expected return statement. This way, we make functions less error-prone and get early notifications for any type mismatch.
Top comments (1)
Absolutely love this article series on TypesScript, you brilliantly explain some of the most common scenarios devs encounter with TS. I noticed a very small nit-picky thing in this article that I just wanted to have clarified. When I double check the Mozilla documentation it seems like it should be "param destructuring" intstead of "argument destructuring": developer.mozilla.org/en-US/docs/G...