DEV Community

Cover image for TypeScript Types Deep Dive - Part 3: Functions
Jaime González García
Jaime González García

Posted on • Originally published at barbarianmeetscoding.com

TypeScript Types Deep Dive - Part 3: Functions

This article was originally published on Barbarian Meets Coding.

TypeScript is a modern and safer version of JavaScript that has taken the web development world by storm. It is a superset of JavaScript that adds in some additional features, syntactic sugar and static type analysis aimed at making you more productive and able to scale your JavaScript projects.

This is the third part of a series of articles where we explore TypeScript's comprehensive type system and learn how you can take advantage of it to build very robust and maintainable web apps. Today, we shall look at functions!

Haven't read the first and second parts of this series? If you haven't you may want to take a look. There's lots of interesting and useful stuff in there.

Functions are one of the most fundamental composing elements of a JavaScript program, and that doesn't change at all in TypeScript. The most common way in which you'll use types in functions within TypeScript is inline, intermingled with the function itself.

Imagine a simple JavaScript function to add a couple of numbers:

function add(a, b){
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

Although, since there's no static typing in JavaScript, there's nothing saying you will only add numbers with this function, you could add anything (which isn't necessarily a bug, it could be a feature).

add(1, 2)            // => 3
add(1, " banana")    // => "1 banana"
add(22, {"banana"})  // => "1[object Object]"
add([], 1)           // => "1"
Enter fullscreen mode Exit fullscreen mode

In our specific context though, where we're trying to build a magic calculator to help us count the amount of dough we need to bake 1 trillion gingerbread cookies (cause we love Christmas, and baking, and we're going to get that Guinness world record once and for all).

So we need a and b to be numbers. We can take advantage of TypeScript to make sure that the parameters and return types match our expectations:

// Most often you'll type functions inline
function add(a: number, b: number): number{
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

So when we exercise this function it works only with numbers:

add(1, 2)            // => 3
add(1, " banana")    // => 💥
add(22, {"banana"})  // => 💥
add([], 1)           // => 💥
Enter fullscreen mode Exit fullscreen mode

Since the TypeScript compiler is quite smart, it can infer that the type of the resulting operation of adding two numbers will be another number. That means that we can omit the type of the returned value:

function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

And if you prefer the arrow function notation you can write it like this:

const add = (a: number, b: number) => a + b;
Enter fullscreen mode Exit fullscreen mode

TypeScript implements control flow-based type analysis which is a complicated way of saying that it runs through your code and can compute and understand the types of the different variables and the resulting values of the operations performed. Using this information it can infer types and save you lots of work (like above), or even refine the types within the body of a function to offer better error messages, more type safety and improved statement completion (we shall see more of that in future articles).

Typing functions inline will be by far the most common way in which you'll use types with functions in TypeScript. Now let's dive further into the different things you can do with parameters and typing functions as values.

Optional Parameters

JavaScript functions can be extremely flexible. For instance, you can define a function with a set of parameters but you don't necessarily need to call the function with that same amount of parameters.

Let's go back to the add function:

function add(a, b) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

In JavaScript, there's no one stopping you from calling this function like so:

add(1, 2, 3); // => 3
add(1, 2);    // => 3
add(1);       // => NaN
add();        // => NaN
Enter fullscreen mode Exit fullscreen mode

TypeScript is more strict. It requires you to write more intentional APIs so that it can, in turn, help you adhere to those APIs. So TypeScript assumes that if you define a function with two params, well, you are going to want to call that function using those two params. Which is great because if we define and add function like this:

function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript will make sure that we call that function as the code author designed it, and thus avoid those awful corner cases that resulted in NaN previously:

add(1, 2, 3); // => 💥 Expected 2 arguments, but got 3
add(1, 2);    // => 3
add(1);       // => 💥 Expected 2 arguments, but got 1
add();        // => 💥 Expected 2 arguments, but got 0
Enter fullscreen mode Exit fullscreen mode

It is important to keep the flexibility of JavaScript, because there will be legitimate cases where parameters should be optional. TypeScript lets you be as flexible as you are accustomed to in JavaScript, but you need to be intentional by explicitly defining whether a parameter is optional or not.

Imagine we're adding some logging to our application to have a better understanding of how our users interact with it. It is important to learn how our users use our applications so that we can make informed decisions as to which features are more or less important, more or less useful, how we can make important features more easily discoverable, etc... So we define this logging function:

function log(msg: string, userId) {
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

Which we can use like this:

log("Purchased book #1232432498", "123fab");
Enter fullscreen mode Exit fullscreen mode

However, in our system, a user is not required to log in. Which means that the userId may or may not be available. That is, the userId parameter is optional. We can model that in TypeScript using optional parameters like so:

// Optional params
function log(msg: string, userId?: string){
  console.log(new Date(), msg, userId ?? 'anonymous user');
}
Enter fullscreen mode Exit fullscreen mode

So that now the function can be called omitting the second parameter:

log("Navigated to about page");
Enter fullscreen mode Exit fullscreen mode

or with an undefined as second parameter:

// get userId from user management system
// because the user isn't logged in the system
// returns undefined
const userId = undefined;
log("Navigated to home page", userId);
Enter fullscreen mode Exit fullscreen mode

This gives you a hint that the optional param is a shorthand for this:

function log(msg: string, userId: string | undefined){
  console.log(new Date(), msg, userId ?? 'anonymous user');
}
Enter fullscreen mode Exit fullscreen mode

Optional parameters always have to be declared at the end of a function parameter list. This makes sense because in the absence of an argument it would be impossible for the TypeScript compiler to know which param one is trying to refer to when calling a function. If you happen to make this mistake when writing a function the TypeScript compiler will immediately come to your aid with the following message: 💥 A required parameter cannot follow an optional parameter.

Default Parameters

I don't quite enjoy having undefined values rampant in my functions (for the many reasons we discussed earlier), so when possible I favor default parameters over optional parameters.

Using default parameters we could rewrite the function above as:

// Default params
function log(msg: string, userId = 'anonymous user'){
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

This function behaves just like our previous function:

log("Navigated to about page");
log("Sorted inventory table", undefined);
log("Purchased book #1232432498", "123fab");
Enter fullscreen mode Exit fullscreen mode

But there's no null reference exception waiting to happen.

Rest Parameters

JavaScript has this nifty feature called rest parameters that lets you define variadic functions. A variadic function is the fancy name of a function that has indefinity arity which is yet another fancy way to say that a function can take any number of arguments.

Imagine we'd like to create a logger that lets us log any arbitrary number of things attached to a timestamp that describes when those things happened. In JavaScript we would write the following function:

function log(...msgs){
  console.log(new Date(), ...msgs);
}
Enter fullscreen mode Exit fullscreen mode

And in TypeScript, since msgs is essentially an array of arguments we'll annotate it like so:

// Typed as an array
function log(...msgs: string[]){
  console.log(new Date(), ...msgs);
}
Enter fullscreen mode Exit fullscreen mode

And now we can use it to pass in as many arguments as we like:

log('ate banana', 'ate candy', 'ate doritos');
// Thu Dec 26 2019 11:10:16 GMT+0100 
// ate banana
// ate candy
// ate doritos
Enter fullscreen mode Exit fullscreen mode

Since it is a fancy variadic function it will just gobble all those params. Also, Thursday December 26th was a cheat day in this household.

Typing Functions as Values

Ok. So far we've seen how you type a function inline using a function declaration for the most part. But JavaScript is very, very fond of functions, and of using functions as values to pass them around and return them from other functions.

This is a function as a value (which we store inside a variable add):

const add = (a: number, b: number) => a + b;
Enter fullscreen mode Exit fullscreen mode

What is the type of the variable add? What is the type of this function?

The type of this function is:

(a: number, b: number) => number;
Enter fullscreen mode Exit fullscreen mode

Which means that instead of using inline types we could rewrite the add function like so:

const add : (a: number, b: number) => number = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

or using an alias:

type Add = (a: number, b: number) => number
const add : Add = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

This example above is quite intesting because the type information is flowing in the opposite way to what we're used to. So far we've been defining types on the expression to the right which flow to the variable that is being assigned (on the left). In this case however, we're defining the type of the variable on the left, and the types flow to the expression on the right. Interesting, isn't it?

This feature of been able to grab types from the context is known as contextual typing and it is a great feature to have because it improves TypeScript type inference capabilities and saves you from typing more annotations than the minimum required.

If this sounds interesting, you may want to take a look at the documentation on TypeScript type inference.

After rewriting the function to use the new full-blown type definition, TypeScript would nod at us knowingly, because it can roll with either inline types or these other separate type definitions. If you take a look at both ways of typing this function side by side:

// # 1. Inline
const add = (a: number, b: number) => a + b;

// # 2. With full type definition
const add : (a: number, b: number) => number = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

You are likely to prefer option 1 since it's more pleasant, easier to read and the types are very near to the params they apply to which eases understanding. So when is option 2 useful?

Option 2 or full type definitions is useful whenever you need to store a function, and when working with higher-order functions.

A higher-order function is a function that either takes another function as a paremeter or returns a function. You can learn more about higher-order functions and other functional programming concepts in this excellent article on the topic.

Let's illustrate the usefulness of typing functions as values with an example. Imagine we want to design a logger that only logs information under some circumstances. This logger could be modelled as a higher-order function like this one:

// Takes a function as a argument
function logMaybe(
  shouldLog: () => bool,
  msg: string){
    if (shouldLog()) console.log(msg);
}
Enter fullscreen mode Exit fullscreen mode

The logMaybe function is a higher-order function because it takes another function shoudLog as a parameter. The shouldLog function is a predicate that returns whether or not something should be logged.

We could use this function to log whether some monster dies a horrible death like so:

function attack(target: Target) {
  target.hp -= 10;
  logMaybe(
     () => target.isDead, 
     `${target} died horribly`
  );
}
Enter fullscreen mode Exit fullscreen mode

Another useful use case would be to create a factory of loggers:

type Logger = (msg: string) => void
// Returns a function
function createLogger(header: string): Logger {
    return function log(msg: string) {
       console.log(`${header} ${msg}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

createLogger is a higher-order function because it returns another function of type Logger that lets you log strings. We can use createLogger to create loggers to our heart's content:

const jaimeLog = createLogger('Jaime says:')

jaimeSays('banana');
// Jaime says: banana
Enter fullscreen mode Exit fullscreen mode

TypeScript is great at inferring return types so we don't really need to explicitly type the returning function. This would work as well:

function createLogger(header: string) {
    return function log(msg: string) {
       console.log(`${header} ${msg}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

Function Overloading

One of the features I kind of miss from strongly typed languages like C# is function overloading. The idea that you can define multiple signatures for the same function taking a diverse number of parameters of different types, and upon calling that function the compiler will be able to discriminate between functions and select the correct implementation. This is a very nice way to provide slightly different APIs to solve the same problem. Like, the problem of raising an army of the undead:

raiseSkeleton()
// don't provide any arguments and you raise an skeleton
// => raise a skeleton
raiseSkeleton(4)
// provide a number and you raise a bunch of skeletons
// => raise 4 skeletons
raiseSkeleton('king')
// provide a string and you raise a special type of skeleton
// => raise skeleton king
Enter fullscreen mode Exit fullscreen mode

JavaScript however doesn't have a great support for function overloading. You can mimick function overloading in JavaScript but it does require a bunch of boilerplate code to manually discriminate between function signatures. For instance, a possible implementation for the raiseSkeleton function above could be this:

function raiseSkeleton(options) {
  if (typeof options === 'number') {
    raiseSkeletonsInNumber(options)
  } else if (typeof options === 'string') {
    raiseSkeletonCreature(options)
  } else {
    console.log('raise a skeleton')
  }

  function raiseSkeletonsInNumber(n) {
    console.log('raise ' + n + ' skeletons')
  }
  function raiseSkeletonCreature(creature) {
    console.log('raise a skeleton ' + creature)
  }
}
Enter fullscreen mode Exit fullscreen mode

You can read more about the perils of function overloading in JavaScript in this other article.

TypeScript tries to lessen the burden of writing function overloading somewhat but it doesn't get all the way there since it is still a superset of JavaScript. The part of function overloading in TypeScript that is really pleasant is the one concerning the world of types.

Let's go back to the log function we used in earlier examples:

function log(msg: string, userId: string){
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

The type of that function could be defined by this alias:

type Log = (msg: string, userId: string) => void
Enter fullscreen mode Exit fullscreen mode

And this type definition is equivalent to this other one:

type Log = {
  (msg: string, id: string): void
}
Enter fullscreen mode Exit fullscreen mode

If we wanted to make the log function provide multiple APIs adapted to different use cases we could expand the type definition to include multiple function signatures like this:

type Log = {
  (msg: string, id: string): void
  (msg: number, id: string): void
}
Enter fullscreen mode Exit fullscreen mode

Which now would allow us to record both string messages as before, but also message codes that are messages obfuscated as numbers which we can match to specific events in our backend.

Following this same approach, a type definition for our raiseSkeleton function would look like this:

type raiseSkeleton = {
  (): void
  (count: number): void
  (typeOfSkeleton: string): void
}
Enter fullscreen mode Exit fullscreen mode

Which we can attach to the real implementation in this manner:

const raiseSkeleton : raiseSkeleton = (options?: number | string) => {
  if (typeof options === 'number') {
    raiseSkeletonsInNumber(options)
  } else if (typeof options === 'string') {
    raiseSkeletonCreature(options)
  } else {
    console.log('raise a skeleton')
  }

  function raiseSkeletonsInNumber(n: number) {
    console.log('raise ' + n + ' skeletons')
  }
  function raiseSkeletonCreature(creature: string) {
    console.log('raise a skeleton ' + creature)
  }
}
Enter fullscreen mode Exit fullscreen mode

And alternative type definition which doesn't require the creation of an alias (but which I find quite more verbose) is the following:

// Alternative syntax
function raiseSkeleton(): void;
function raiseSkeleton(count: number): void;
function raiseSkeleton(skeletonType: string): void;
function raiseSkeleton(options?: number | string): void {
  // implementation
}
Enter fullscreen mode Exit fullscreen mode

If we take a minute to reflect about function overloading in TypeScript we can come to some conclusions:

  • TypeScript function overloading mostly affects the world of types
  • Looking at a type definition it is super clear to see the different APIs an overloaded function supports, which is really nice
  • You still need to provide an implementation underneath that can handle all possible cases

In summary, function overloading in TypeScript provides a very nice developer experience for the user of an overloaded function, but not so nice a experience for the one implementing that function. So the code author pays the price to provide a nicer DX to the user of that function.

Yet another example is the document.createElement method that we often use when creating DOM elements in the web (although we don't do it as much in these days of frameworks and high-level abstractions). The document.createElement method is an overloaded function that given a tag creates different types of elements:

type CreateElement = {
  (tag: 'a'): HTMLAnchorElement
  (tag: 'canvas'): HTMLCanvasElement
  (tag: 'svg'): SVGSVGElement
  // etc...
}
Enter fullscreen mode Exit fullscreen mode

Providing an API like this in TypeScript is really useful because the TypeScript compiler can help you with statement completion (also known in some circles as IntelliSense). That is, as you create an element using the a tag, the TypeScript compiler knows that it will return an HTMLAnchorElement and can give you compiler support to use only the properties that are available in that element and no other. Isn't that nice?

Argument Destructuring

A very popular pattern for implementing functions these days in JavaScript is argument destructuring. Imagine we have an ice cone spell that we use from time to time to annoy our neighbors. It looks like this:

function castIceCone(caster, options) {
  caster.mana -= options.mana;
  console.log(`${caster} spends ${options.mana} mana 
and casts a terrible ice cone ${options.direction}`);
}
Enter fullscreen mode Exit fullscreen mode

I often use it with the noisy neighbor upstairs when he's having parties and not letting my son fall asleep. I'll go BOOOOM!! Ice cone mathafackaaaa!

castIceCone('Jaime', {mana: 10, direction: "towards the upstairs' neighbors balcony for greater justice"});
// => Jaime spends 10 mana and casts a terrible ice cone
// towars the upstairs' neighbors balcony for greater justice
Enter fullscreen mode Exit fullscreen mode

But it feels like a waste to have an options parameter that doesn't add any value at all to this function signature. A more descriptive and lean alternative to this function takes advantage of argument destructuring to extract the properties we need, so we can use them directly:

function castIceCone(caster, {mana, direction}) {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

This removes a lot of noise and it also allows us to set sensible defaults inline which makes sense because the second paremeter should be optional:

function castIceCone(
  caster, 
  {mana=1, direction="forward"}={}) {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

So how do we type this param in TypeScript? You may be tempted to write something like this:

function castIceCone(
  caster: SpellCaster, 
  {mana: number, direction:string}): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

But it wouldn't work. Because that's legit ES2015 destructuring syntax. It's the pattern you use when you want to project a property of an object into a variable with a different name. In the example above we're projecting options.mana into a variable named number, and options.direction into another variable string. Ooops.

The most common way to type the function above is to provide a type for the whole parameter (just like we normally do with any other params):

function castIceCone(
  caster: SpellCaster, 
  {mana=1, direction="forward"}={} : {mana?: number, direction?:string} 
  ): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

Both parameters are optional because they have defaults so the user of this function doesn't have to provide these as arguments if they don't want. There's something particularly interesting about this example that you may not have noticed: the types of the parameters as defined in the function declaration are not the types of the parameters inside the function. What? The caller of this function and the body of this function see different types. What??

  • A caller of castIceCone sees mana as required to be of type number or undefined. But since mana has a default value, within the body of the function it will always be of type number.
  • Likewise, the caller of the function will see direction as been string or undefined whilst the body of the function knows it'll always be of type string.

TypeScript argument destructuring can get quite verbose very fast so you may want to consider declaring an alias:

type IceConeOptions = {mana?: number, direction?: string}
function castIceCone(
  caster: SpellCaster, 
  {mana=1, direction="forward"}={} : IceConeOptions): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

or opting out of inline types entirely:

type castIceCone = (caster: SpellCaster, options: IceConeOptions) => void;

const castIceCone : castIceCone = (
  caster, 
  { mana = 1, direction = "forward" } = {}
  ) => {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

In Summary

JavaScript functions are extremely flexible. TypeScript functions are just as flexible and will support the most common patterns used with functions in JavaScript but they expect you to be more intentional and explicit with the APIs that you design. This isn't a bad thing, it means that your APIs are constrained to only the use cases that you as an author define. This additional constraint will help prevent your APIs from being used in mischiveous or unexpected ways (like calling a function with no arguments when it expects two argumenst).

The most common way to type your functions is using types inline, having the types sitting just beside the stuff they affect: your arguments and return types. TypeScript is pretty good at inferring return types by taking a look at what happens inside your function, so in lots of cases you'll ber OK omitting your return values.

The function patterns that you're accustomed to in JavaScript are supported in TypeScript. You can use optional parameters to define functions that may or may not receive some arguments. You can write type safe functions with default params, rest params and argument destructuring. You even have a much better support for writing function overloads than you do in JavaScript. And you have the possibility of expressing the types of functions as a value, which you'll often use when writing higher-order functions.

In summary, TypeScript has amazing features to help you writing more robust and maintainable functions. Wihoo!

Hope you enjoyed this article! Take care and be kind to the people around you!

Top comments (3)

Collapse
 
itsjzt profile image
Saurabh Sharma

Thanks for the great article Jaime,

One thing I would like to add is: never

Lets say we have a function that can return data or throw Error
Then function type would be () => Data | never

Doc's are little unclear about it but here are some of the use cases stackoverflow.com/questions/422918...

Collapse
 
vintharas profile image
Jaime González García

Thank you Saurabh! You are right! I kind of completely forgot writing about void and never. Thank you for your comment!

Collapse
 
itsjzt profile image
Saurabh Sharma

They are usually not that much used tho