Barbarian Meets Coding
barbarianmeetscoding

WebDev, UX & a Pinch of Fantasy

18 minutes readtypescript

TypeScript Types Deep Dive - Part 3: Functions

The TypeScript logo followed by the subtitle Types Deep Dive
An epic journey of discovery into the mysterious world of TypeScript's type system. Part 3 discuss how to use types in functions within TypeScript.

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!

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;
}

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"

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;
}

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

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

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;
}

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

const add = (a: number, b: number) => a + b;

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;
}

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

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;
}

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

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);
}

Which we can use like this:

log("Purchased book #1232432498", "123fab");

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');
}

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

log("Navigated to about page");

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);

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');
}

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);
}

This function behaves just like our previous function:

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

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);
}

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);
}

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

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;

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;

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;

or using an alias:

type Add = (a: number, b: number) => number
const add : Add = (a, b) => a + b;

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;

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.

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);
}

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`
  );
}

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}`);
    }
}

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

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}`);
    }
}

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

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)
  }
}

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);
}

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

type Log = (msg: string, userId: string) => void

And this type definition is equivalent to this other one:

type Log = {
  (msg: string, id: string): void
}

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
}

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
}

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)
  }
}

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
}

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...
}

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}`);
}

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

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}`);
}

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}`);
}

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}`);
}

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}`);
}

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}`);
}

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}`);
}

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!


Jaime González García

Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.Jaime González García