TypeScript Types Deep Dive - Part 3: Functions
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
seesmana
as required to be of typenumber
orundefined
. But sincemana
has a default value, within the body of the function it will always be of typenumber
. - Likewise, the caller of the function will see
direction
as beenstring
orundefined
whilst the body of the function knows it’ll always be of typestring
.
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!
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.