Sunday, January 10, 2021

How to apply type annotations to functions in TypeScript

This post explores the various ways of ascribing functions with type annotations in TypeScript. To fully appreciate the various ways of ascribing type annotations to functions, we would first take a quick look at the various ways functions can be defined in JavaScript. Once that is done, we then look at how to bring TypeScript's static type annotation into the picture. 

There are mainly two ways of defining functions in JavaScript. Namely: 
  • Function expressions 
  • Function declarations. 
Let’s quickly go over how these two work.

Function expressions


JavaScript allows creating functions by defining them as expressions that can be assigned to a variable. Defining such function expressions can make use of the function keyword or via the use of Arrow functions. 

For example, defining an add function can take either of these forms: 

Using the function keyword:

let add = function(a,b) { return a + b }

Using Arrow functions, It takes the form:

let add = (a,b) => { return a + b }

Functions defined in either of these ways can be called like any other function:
 
console.log(add(1,2)) // logs 3 

Function declaration


Using function declaration is the regular way of defining a function that everyone knows.

It looks like this:

function add(a,b) {
  return a + b;
}

The above is a declaration, function declaration that creates an identifier, in this case, add, that can be later used as a function. The created add identifier can be called as follows:

console.log(add(1,2)) // logs 3

Now before we go ahead to see how to apply TypeScript’s type annotations, another important thing to be aware of, is the fact that functions in JavaScript are also objects!

Yes, a function in JavaScript is a JavaScript object.

This means the function add created above, either via function declaration or function expression, is also an object and can have properties assigned to it like any other object in JavaScript.

That is:

add.serial = "A01"
console.log(add.serial) // logs A01

For a more in-depth take on functions being objects, see Understanding Constructor Function and this Keyword in Javascript

Knowing that functions are also objects is important, as this also influences the syntax that can be used for type annotation in TypeScript. We will see how later in the post. 

Now that we have covered the basics, let’s now go into applying type annotations to functions. 

We will start with function expressions.

Adding Type Annotations to Function Expressions


Adding type annotations to function expressions is straight forward because the definition is an expression that is assigned to a variable. 

This makes it obvious where to place the type annotation, which is right after the variable declaration.

Knowing this, adding type annotation to the add function will look like this: 

let add: (a: number, b: number) => number = (a,b) => { return a + b }

Notice that the actual function definition remains unchained. That is (a,b) => { return a + b } remains the way it was in the JavaScript version, and no type annotations are added to the function parameters, but TypeScript is able to infer the types based on type annotation ascribed to the variable. 

That being said, it is also possible to update the function definition to have type annotations. That is:

let add: (a: number, b: number) => number = 
         (a:number,b:number):number => { return a + b }

In which case, the type annotation placed after the variable becomes redundant and can be removed, which leads to another way of typing the function expressions. This can be seen below: 

let add = (a: number, b:number): number => { return a + b }

This demonstrates an important point. Which is, when typing functions in TypeScript, there are two ways to go about it. One way is to ascribe type annotations to the function parameters and return type. The other way is to ascribe annotation to the variable that holds the function

Ascribing type annotations to variables is possible when using function expression since function expression are assigned to variables. Function expression can also choose to have parameters in their definition annotated, although often time this is not needed. 

Another important point is when ascribing types to variable holding functions, the syntax used resembles how Arrow functions are used, that is it makes use of "=>". And this is the only way to annotate variables with functions.

For example, this is correct:

let add: (a: number, b: number) => number 
       = (a,b):number => { return a + b }

While this leads to a syntax error:

let add: (a: number, b: number): number = 
         (a,b):number => { return a + b }

Adding Type Annotations to Function Declaration


The add JavaScript function defined via function declaration:

function add(a,b) {
  return a + b;
}

With TypeScripts type annotations applied becomes

function add(a: number, b: number): number {
  return a + b;
}

Since there is no other way the TypeScript compiler can infer the types of the function parameters, the type annotation has to be supplied. 

One might ask, what then is the type of the add function? 

For example given a variable name defined as:

let name: string = “John Doe”;

When the question is asked, what is the type of name it is easy to see it is string

When the same question is asked for the add function defined using function expressions, that is

let add: (a: number, b: number) => number = (a,b) => { return a + b }

It is easy to respond that the type is (a: number, b: number) => number

But what about the add function defined using function declaration? 

To help answer this question we can use the combination of an IDE and the typeof operator of TypeScript. The typeof operator when used in the type signature context can help extract the type of a value. 

So to answer the question, what is the type of the add function defined using function declaration, we use typeof on add, in the type signature context and using a tool that offers IntelliSense, in this case, the TypeScript playground, we can see what the type is: 


And as can be seen above, the type of add when defined using function declaration is (a: number, b: number) => number, which is exactly the same type annotation of the same function when defined using the function expression!

Typing functions using Call signature of an object literal type


Remember we mentioned that functions are also objects. And we showed how we can add properties to functions as we do to objects. Well, functions being objects also provide us another way of supplying type information about functions. 

A question a curious reader might ask upon being told that functions are objects in JavaScript is this: if functions are objects, how come we can call them? How come functions can be called by appending () to the end of the function? That is something like functionName()

The answer to that question is in realising that the syntax functionName() is really a syntactic sugar for either functionName.call() or functionName.apply(). That is, calling a function, is really nothing but assessing the apply or call property of the object representing that function. 

See MDN entries for Function.prototype.apply() and Function.prototype.call() for more information. 

This knowledge helps in understanding another way of typing functions, which is using the call signature. Doing that builds on how object literal can be used to specify types. 

For example to provide type annotation describing an object with a property name of type string, and property age, of type number, the following interface can be created and used:

interface Person {
 name: string
 age: number
 greet(): string
}

let john: Person = {
 name: "John Doe",
 age: 20,
 greet() {
   return “hello world”
 }
} 

The type annotation outlines the property name together with a type annotation.

Knowing this, and knowing that functions are also objects, that can be called via a call, or apply property then we can provide a type annotation to our add function as shown below: 

interface Adder {
  apply(a: number, b: number): number
  call(a: number, b: number): number
}

And we can then use this Adder in typing the add function. This looks like this: 

let add: Adder = (a: number, b: number) => { return a + b }

Note that even though this type checks, confirming that the created Adder type can be used to annotate our add function, we still have to annotate the type parameters, because TypeScript is unable to infer their types from the provided Adder annotation used for the variable. 

We can go one step further in the definition of Adder by removing the need to specify apply and call explicitly. This is because as we already know that a function (which is an object) can have its apply and call property called without having to explicitly specify them. That is, the call signature of a function is a syntactic sugar that will expand to explicitly use either apply or call. We can apply this knowledge of the call signature to the type definition by removing the apply and call. Doing that we end up with:

interface Adder {
  (a: number, b: number): number
}

This way of providing type annotations to functions is usually referred to as using the call signature of an object literal type. 

It is worth noting that in TypeScript, the keyword type and interface are interchangeable in most cases hence, the above can also be defined using type instead of interface

Summary


These conclude the overview of how functions can be typed in TypeScript. A quick outline of the key points is listed below:

  1. The way functions are typed in TypeScript depends on the ways functions can be created in JavaScript.
  2. Functions can be created either via function declaration or function expressions. 
  3. They are two main ways to ascribe type annotations to functions. Typing the parameters and return type of the function, or typing the variable that holds the function
  4. Functions defined using function declaration can only be typed by providing type annotation to parameters and return value. A function expression can be typed by providing the type annotation to the variable that holds the function expression. Also, it is possible to ascribe type to the function parameters defined in the function expression, this is usually redundant. It is only needed in cases where the compiler cannot infer their types based on the type annotation ascribed to the variable.
  5. When typing a variable that holds a function, the type annotation makes use of => to specify the return type. Using this Arrow function style is the only way to type variables holding function expressions.
  6. Also, Functions are just objects! And this influences the third way of typing functions which is called: call signature using object literals.



I am writing a book: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.

No comments: