Functions
In this section of the chapter, we will take a look at functions and their definitions, and how the TypeScript language can be used to introduce further type safety whenever functions are used. Functions can use many of the concepts that we have already discussed, including optional parameters and spread syntax. We will also discuss how we can define a function signature in such a manner that if a function defines another function as a parameter, we can make sure that the function we pass in has the correct parameters. Finally, we will take a look at how to define function overrides.
Optional parameters
Similar to how we have seen tuples using optional elements, we can specify that a function can have optional elements in the same way, using the question mark (?
). Consider the following code:
function concatValues(a: string, b?: string) {
console.log(`a + b = ${a + b}`);
}
concatValues("first", "second");
concatValues("third");
Here, we have defined a function named concatValues
that has two parameters, a
and b
, both of type string. The second argument, b
, however, has been marked as optional using the question mark after the argument name, that is, b?: string
. We then call this function with two parameters, and then with only a single parameter. The output of this code is as follows:
a + b = firstsecond
a + b = thirdundefined
Here, we can see that the first call to the concatValues
function concatenates the strings "first"
and "second"
, logging the value of "firstsecond"
to the console. The second call to the concatValues
function only provided a value for the first argument, as the second argument was marked as optional.
This second call to the concatValues
function produces the output "thirdundefined"
, as we have not specified a value for the second argument. This means that the argument b
was not been specified and is thus undefined
.
Note that any optional parameters must be listed last in the parameter list of the function definition. You can have as many optional parameters as you like, as long as non-optional parameters precede the optional parameters.
Default parameters
A variant of the optional parameter syntax allows us to specify a default value for a parameter, if it has not been supplied. Consider the following code:
function concatWithDefault(a: string, b: string = "default") {
console.log(`a + b = ${a + b}`);
}
concatWithDefault("first", "second");
concatWithDefault("third");
Here, we have defined a function named concatWithDefault
that has two parameters, a
and b
, both of type string. Note, however, the definition of the parameter named b
. We are assigning the value of "default"
to this parameter within the function definition. This assignment will automatically make this parameter optional, and we do not use the question mark syntax to define this parameter as optional. Note, too, that the use of the explicit type for the parameter b
, as in :string
, is also optional, as the compiler will infer the type from the default value, which in this case is type string.
We then call this function with two arguments, and then with just a single argument. The output of this code is as follows:
a + b = firstsecond
a + b = thirddefault
Here, we can see that when we supply two arguments to the concatWithDefault
function, the function will concatenate the arguments as expected. When we only supply a single argument, the second argument will default to the value "default"
.
Rest parameters
Interestingly, the parameters specified in a JavaScript function are all optional. Even if a JavaScript function specifies parameters in its function definition, we do not need to supply them when calling the function. In a quirky twist of the language, even if we do not specify any parameters in a function definition, we can still access the values that were provided when the function was invoked. Consider the following JavaScript code:
function testArguments() {
for (var i = 0; i < arguments.length; i++) {
console.log("argument[" + i + "] = " + arguments[i]);
}
}
testArguments(1, 2);
testArguments("first", "second", "third");
Here, we have defined a JavaScript function named testArguments
that does not specify any parameters. We then create a for
loop to loop through the values of an array named arguments
. If an array element is found, we log the value of the array element to the console. All JavaScript functions automatically have access to a special variable, named arguments
, that can be used to retrieve all of the arguments that were used when the function is invoked.
We then invoke the testArguments
function twice, once with the arguments 1
and 2
, and the second time with the arguments "first"
, "second"
, and "third"
.
The output of this code is as follows:
argument[0] = 1
argument[1] = 2
argument[0] = first
argument[1] = second
argument[2] = third
Here, we can see a log of the arguments that were used to invoke the testArguments
function. The first time we invoked the function, we used the arguments of 1
and 2
. The second time we invoked this function, we used the arguments of "first"
, "second"
, and "third"
.
In order to express the equivalent function definition in TypeScript, we will need to use rest syntax, as follows:
function testArguments(...args: string[] | number[]) {
for (let i in args) {
console.log(`args[${i}] = ${args[i]}`);
}
}
testArguments("1");
testArguments(10, 20);
Here, we have defined a function named testArguments
using rest syntax, that is, the three dots ( ...
), to specify that the function can be called with any number of parameters. We are also using a type union here to indicate that the variable parameters can be of type string or of type number.
We then invoke the testArguments
function with one argument, which is the string "1"
, and then invoke it with two numbers, namely 10
and 20
. The output of this code is as follows:
args[0] = 1
args[0] = 10
args[1] = 20
Here, we can see that the testArguments
function can be called with multiple arguments, and because the function definition allows these parameters to be either of type string or of type number, we are able to mimic the functionality of the earlier JavaScript function.
Function callbacks
One of the most powerful features of JavaScript, and in fact the technology that NodeJS was built on, is the concept of callback functions. A callback function is a function that is passed in as an argument to another function, and is then generally invoked within the original function. In other words, we are calling a function and telling it to go and do what it needs to do, and when it is finished, to call the function that we have supplied.
Just as we can pass a value into a function, we can also pass a function into a function as one of its arguments.
This is best illustrated by taking a look at some JavaScript code, as follows:
var myCallback = function (text) {
console.log("myCallback called with " + text);
}
function withCallbackArg(message, callbackFn) {
console.log("withCallback called, message : " + message);
callbackFn(message + " from withCallback");
}
withCallbackArg("initial text", myCallback);
Here, we start with a function named myCallback
that accepts a single parameter named text
. It simply logs the value of the text
argument to the console. We then define a function named withCallbackArg
, which has two parameters, named message
and callbackFn
. This function logs a message to the console using the message
argument, and then invokes the function passed in as the callbackFn
parameter. When invoking the function passed in, it invokes it with some text indicating that it was called within the withCallback
function.
Finally, we invoke the withCallbackArg
function with two arguments. The first argument is the text string of "initial text"
, and the second argument is the myCallback
function itself. The output of this code is as follows:
withCallback called, message : initial text
myCallback called with initial text from withCallback
As we can see from this output, the withCallbackArg
function is being invoked and logging the "withCallback called, message : initial text"
message to the console. It is then invoking the function that we passed into it as a callback function, which is the myCallback
function.
Unfortunately, JavaScript cannot tell until it executes this code whether the second argument passed into the withCallbackArg
function is actually a function. Let's test this theory by passing in a string for the callbackFn
parameter, instead of an actual function, as follows:
withCallbackArg("text", "this is not a function");
Here, we are invoking the withCallbackArg
function with two string values, instead of a string value and a function signature, as the function is expecting. The output of this code is as follows:
withCallback called, message : text
TypeError: callbackFn is not a function
at withCallbackArg (javascript_samples.js:75:5)
at Object.<anonymous> (javascript_samples.js:80:1)
at Module._compile (internal/modules/cjs/loader.js:1133:30)
Here, we can see that we have caused a JavaScript runtime exception to occur, because the second argument that we passed into the withCallbackArg
function was not a function, it was just a string.
JavaScript programmers, therefore, need to be careful when working with callbacks. The most useful technique for avoiding this sort of runtime error is to check if the argument passed in is actually a function using typeof
, similarly to how we used typeof
when creating type guards. This leads to a lot of defensive code being written to ensure that when a function is expecting a function to be passed in as a callback, it really is a function, before attempting to invoke it.
Function signatures as parameters
TypeScript uses its strong typing rules to ensure that if we define a function that needs a callback function, we can ensure that this function is provided correctly. In order to specify that a function parameter must be a function signature, TypeScript introduces the fat arrow syntax, or () =>
, to indicate a function signature. Let's rewrite our previous JavaScript code using this syntax as follows:
function myCallback(text: string): void {
console.log(`myCallback called with ${text}`);
}
function withCallbackArg(
message: string,
callbackFn: (text: string) => void
) {
console.log(`withCallback called, message : ${message}`);
callbackFn(`${message} from withCallback"`);
}
Here, we have defined a strongly typed function named myCallback
that has a single parameter named text
, which is of type string, and returns void. We have then defined a strongly typed function named withCallbackArg
that also has two parameters. The first parameter is named message
and is of type string, and the second parameter, named callbackFn
, is using the fat arrow syntax, as follows:
callbackFn: (text: string) => void
This syntax defines the callbackFn
parameter as being a function that accepts a single parameter of type string, and returns void.
We can then use this withCallbackArg
function as follows:
withCallbackArg("initial text", myCallback);
withCallbackArg("text", "this is not a function");
Here, we have invoked the withCallbackArg
function twice: once legitimately, by providing a string and a function as arguments, and once in error, by providing two strings as arguments. This code will produce the following error:
error TS2345: Argument of type '"this is not a function"' is not assignable to parameter of type '(text: string) => void'
Here, we can clearly see that the compiler will not allow us to invoke the withCallbackArg
function if we do not provide the second argument as a function with a signature that matches our function definition.
This is a very powerful feature of TypeScript. With its strong typing rules, it is preventing us from providing callback functions that do not conform to the correct function signature. Again, this helps to catch errors at the time of compilation, and not further down the line when the code needs to be actually run and tested.
Function overrides
TypeScript provides an alternative to union types when defining a function and allows a function signature to provide different parameter types. Consider the following code:
function add(a: string, b: string): string;
function add(a: number, b: number): number;
function add(a: any, b: any) {
return a + b;
}
add("first", "second");
add(1, 2);
Here, we have defined a function definition named add
that accepts two parameters, named a
and b
, which are both of type string, and returns a string. We have then defined another function with the same name, add
, that accepts two parameters named a
and b
that are of type number, which returns a number. Note that neither of these function definitions has an actual function implementation.
Finally, we define a function, again with the name of add
, that accepts two parameters named a
and b
but that are of type any
. This function definition also provides a function implementation, which simply returns the addition of the a
and b
arguments.
This technique is used to provide what are known as function overrides. We can call this function with two arguments of type string, or two arguments of type number, as follows:
add("first", "second");
add(1, 2);
add(true, false);
Here, we have invoked the add
function with three types of arguments. Firstly, we invoke it with two arguments of type string. We then invoke it with two arguments of type number. Finally, we invoke the add
function with two arguments of type boolean. This last line of code will generate the following error:
error TS2769: No overload matches this call.
Overload 1 of 2, '(a: string, b: string): string', gave the following error.
Argument of type 'true' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(a: number, b: number): number', gave the following error.
Argument of type 'true' is not assignable to parameter of type 'number'.
Here, we can see that the only valid function signatures are where the arguments a
and b
are both of type string, or where the arguments a
and b
are both of type number. Even though our final function definition uses the type of any, this function definition is not made available and is simply used for the function implementation. We therefore cannot invoke this function with two boolean arguments, as the error shows.
Literals
TypeScript also allows us to use what are known as literals, which are almost a hybrid of enums and type aliases. A literal will limit the allowed values to a set of values specified. A literal can be made of string, number, or boolean values. Consider the following code:
type AllowedStringValues = "one" | "two" | "three";
type AllowedNumericValues = 1 | 20 | 65535;
function withLiteral(input:
AllowedStringValues | AllowedNumericValues) {
console.log(`called with : ${input}`);
}
Here, we have defined a literal named AllowedStringValues
, as well as a literal named AllowedNumericValues
. The syntax used for literals is very similar to the syntax of a type alias, where we use the type
keyword followed by a set of allowed values. Unlike type aliases, however, we are not specifying a set of different types. We are specifying a set of allowed values, which is similar in concept to an enum.
We then have a function named withLiteral
that accepts a single parameter of type AllowedStringValues
, or of type AllowedNumericValues
. This function simply logs the value of the input
argument to the console. We can now use this function as follows:
withLiteral("one")
withLiteral("two");
withLiteral("three");
withLiteral(65535);
withLiteral("four");
withLiteral(2);
Here, we are invoking the withLiteral
function with six values, namely "one"
, "two"
, "three"
, 65535
, "four"
, and 2
. Our literals, however, will only allow the values of "one"
, "two"
, "three"
, 1
, 20
, and 65535
. As such, the last two lines of this code will generate the following errors:
error TS2345: Argument of type '"four"' is not assignable to parameter of type '1 | 20 | "one" | "two" | "three" | 65535'.
error TS2345: Argument of type '2' is not assignable to parameter of type '1 | 20 | "one" | "two" | "three" | 65535'.
These error messages are generated because our literals do not allow the value "four"
or the value 2
to be used.
Literals provide us with another tool that we can use when we need to define a function that accepts a standard string, number, or boolean, but where we need to limit the values provided to a defined set of values.
This concludes our exploration of the use of functions and function definitions with regard to the strong typing that TypeScript provides. We have discussed optional parameters, default parameters, rest syntax, function signatures, and function overrides. We also explored literals and how they can be used to limit the values allowed for function arguments.