Editor’s note: This article was last updated by Yan Sun on 6 February 2024. It now includes sections about declaring function types in TypeScript, using JavaScript’s passy-by-reference
concept for functions, and understanding advanced patterns such as generics and function overloads.
In JavaScript, functions are considered first-class citizens, which means they can be handled like any other type of variable, including numbers, strings, and arrays. This feature allows functions to be passed into other functions, returned from functions, and assigned to variables for later use.
This feature is heavily used in asynchronous code, where functions are often passed into asynchronous functions, often referred to as callbacks. But this can be tricky to use with TypeScript.
TypeScript offers us the fantastic benefits of adding static types and transpilation checks, and it can help us better document what types of variables we expect in our functions. But what happens if we need to pass functions?
It is evident that typing these functions is necessary. However, the question arises: how do we type them, and how do we pass a function in TypeScript? In this tutorial, we will explore TypeScript functions and how to pass them as parameters in our apps.
Most TypeScript developers are familiar with typing simple variables, but constructing a type for a function is a little more complicated.
A function type (note: this link redirects to old TypeScript docs, but it has a much clearer example than the newer ones) is made up of the types of arguments the function accepts and the return type of the function.
We can illustrate a very simple example to demonstrate this:
const stringify = (el : any) : string => { return el + "" } const numberify = (el : any) : number => { return Number(el) } let test = stringify; test = numberify;
If implemented in JavaScript, the above example would work fine and have no issues.
But when we utilize TypeScript, errors are thrown when we try to transpile our code:
- Type '(el: any) => number' is not assignable to type '(el: any) => string'. - Type 'number' is not assignable to type 'string'.
The error message thrown here is descriptive: the stringify
and numberify
function are not interchangeable. They cannot be assigned interchangeably to the test
variable, as they have conflicting types. The arguments they receive are the same (one argument of type any
), but we receive errors because their return types are different.
We could change the return types here to prove our theory is correct:
const stringify = (el : any) : number => { return 1 } const numberify = (el : any) : number => { return Number(el) } let test = stringify; test = numberify;
The above code now works as expected. The only difference is that we changed the stringify
function to match the return type of the numberify
function. Indeed, the return
type was breaking this example.
In TypeScript, we can declare a function type with the type
keyword. The type
keyword in TypeScript allows us to specify the shape of data:
type AddOperator = (a: number, b: number) => number;
Here, we define a type alias named AddOperator
using the type
keyword. It represents a function type that takes two parameters (a
and b
) of type number
and returns a value of type number
.
Another way to declare a function type is to use interface syntax. The below Add
interface represents the same function type as the above AddOperator
function type:
// Using interface for function type interface Add { (a: number, b: number): number; }
Explicitly defining function structures provides a clear understanding of expected inputs and outputs. This enhances code readability, serves as documentation, and simplifies code maintenance.
Another main advantage of declaring function types is the ability to catch errors at compile time. TypeScript’s static typing ensures that functions adhere to the specified types, preventing runtime errors caused by mismatched parameter types or invalid return types.
In the example below, the TypeScript compiler will throw an error, indicating the mismatch between the expected and actual return types:
// Function violating the parameter type const addFn: AddOperator = (a, b) => `${a}${b}`; // Error: Type 'string' is not assignable to type 'number'
Declaring function types allows Integrated Development Environments (IDEs) to offer precise autocompletion.
IntelliSense, a powerful feature offered by modern IDEs, provides us with context-aware suggestions. Function types provide explicit information about parameter types, making it easier to understand the expected inputs. As we start typing function names or parameters, IntelliSense utilizes the declared types to suggest valid options, minimizing errors and saving time.
pass-by-reference
for functionsIn JavaScript/TypeScript, understanding the concepts of pass-by-value
and pass-by-reference
is crucial for working with functions and manipulating data. Primitive types (such as Boolean, null, undefined, String, and Number) are treated as pass-by-value
, while objects (including arrays and functions) are handled as pass-by-reference
.
When an argument is passed to the function, pass-by-value
means a copy of the variable is created, and any modifications made within the function do not affect the original variable. In the example below, we change the value of the variable a
inside the function, but the value of the variable a
outside isn’t changed as a
is passed into the function with pass-by-value
:
const numberIncrement = (a: number) => { a = a + 1; return a; } let a = 1; let b = numberIncrement(a); console.log(`pass by value -> a = ${a} b = ${b}`); // pass by value -> a = 1 b = 2
When an object or array argument is passed to a function, it is treated as pass-by-reference
. The argument is copied as a reference, not the object itself. Thus, changes to the argument’s properties inside the function are reflected in the original object. In the example below, we can observe that the change of the array orignalArray
inside the function affects the orignalArray
outside the function:
const arrayIncrement = (arr: number[]): void => { arr.push(99); }; const originalArray: number[] = [1, 2, 3]; arrayIncrement(originalArray); console.log(`pass by ref => ${originalArray}`); // pass by ref => 1,2,3,99
Contrary to some misconceptions, even though the reference to the object is copied for pass-by-reference
, the reference itself is still passed by value. If the object reference is reassigned inside the function, it won’t affect the original object outside the function.
The below example illustrates reassigning an array reference of originalArray
inside the function, and its original object isn’t affected:
const arrayIncrement = (arr: number[]): void => { arr = [...arr, 99]; console.log(`arr inside the function => ${arr}`); }; //arr inside the function => 1,2,3,99 const originalArray: number[] = [1, 2, 3]; arrayIncrement(originalArray); console.log(`arr outside => ${originalArray}`); // arr outside => 1,2,3
Generics in TypeScript provide a way to write functions that can work with any data type. The following example is sourced from the official TypeScript documentation:
function identity<T>(arg: T): T { return arg; } console.log(identity("Hello, TypeScript!")); console.log(identity(99));
Here, the identity
function can accept and return values of any type. This flexibility allows us to write functions that adapt to various data types.
We can create highly reusable and adaptable functions that work with various data types using generics.
Let’s say we want to create a utility function for searching elements in an array based on a specific criterion. Using generics allows the function to work with arrays of various types and accommodate different search criteria:
function findElements<T>(arr: T[], filterFn: (item: T) => boolean): T[] { return arr.filter(filterFn); }
Here, we create a generic function named findElements
that takes an array arr
and a filterFn
function as parameters. The filterFn
parameter is a callback function that determines whether an element satisfies a particular criterion, returning a Boolean.
Below are a couple of examples in which we use the function from above to deal with number types, object types, and different search criteria. We use the function to filter odd numbers from an array and inexpensive products from an array of products, demonstrating its flexibility with different data types:
const arr: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const oddNumbers: number[] = findElements(arr, (num) => num % 2 === 1); console.log("Odd Numbers:", oddNumbers); interface Product { name: string; price: number; } const products: Product[] = [ { name: "Phone", price: 400 }, { name: "Laptop", price: 1000 }, { name: "Tablet", price: 300 } ]; const cheapProducts = findElements(products, (p) => p.price < 500); console.log('cheap products:', cheapProducts);
The use of generics makes the function highly reusable and adaptable, making it applicable to arrays of primitive types or custom objects without sacrificing type safety.
Function overloads allow us to provide multiple type signatures for a single function. This is particularly useful when a function can accept different combinations of argument types.
To use function overload, we must define multiple overload signatures and an implementation. The overload signature outlines the parameter and return types of a function without including an actual implementation body:
// Overload signature function greeting(person: string): string; function greeting(persons: string[]): string; // Implementation of the function function greeting(input: string | string[]): string { if (Array.isArray(input)) { return input.map(greet => `Hello, ${greet}!`).join(' '); } else { return `Hello, ${input}!`; } } // Consume the function console.log(greeting('Bob')); console.log(greeting(['Bob', 'Peter', 'Sam']));
In the above example, we create a function that demonstrates function overloads accepting parameters, either a string or an array of strings. This function, named greeting
, has two overloads to handle these scenarios. The implementation checks whether the input
parameter is a string or an array of strings and performs the appropriate action for each case.
We can leverage generics to create versatile functions that work with various data types. Additionally, function overload is valuable in enhancing parameter flexibility, allowing functions to accept different types while providing clear expectations for each case.
Interestingly, many other languages will create these function types based on the types of argument, return types, and the number of arguments for the function.
Let’s make one final example to expand on our last working example:
const stringify = (el : any, el2: number) : number => { return 1 } const numberify = (el : any) : number => { return Number(el) } let test = stringify; test = numberify;
Developers familiar with other languages might think the above function examples aren’t interchangeable, as the two function signatures differ.
This example throws no errors, though, and it’s legitimate in TypeScript because TypeScript implements what’s referred to as duck typing.
In duck typing, TypeScript checks if the structure of the assigned function is compatible with the expected type based on the function’s parameters and return type. In this case, both stringify
and numberify
share the same structure: a function that takes one or more parameters (of any type) and returns a number. Despite the difference in the number of parameters between the two functions, TypeScript allows this assignment due to duck typing.
It’s a small note, but it’s important to remember: the number of arguments isn’t utilized in type definitions for functions in TypeScript.
Now, we know precisely how to construct types for our functions. We need to ensure we type the functions that we pass in TypeScript.
Let’s work through a failing example together again:
const parentFunction = (el : () ) : number => { return el() }
The above example doesn’t work, but it captures what we need.
We need to pass it to a parent function as a callback, which can be called later. So, what do we need to change here? We need to:
el
function argumentel
function (if it requires them)Upon doing this, our example should now look like this:
const parentFunction = (el : () => any ) : number => { return el() }
This specific example doesn’t require arguments, but if it did, here is what it would look like:
const parentFunction = (el : (arg: string) => any ) : number => { return el("Hello :)") }
This example is relatively simple to explain the concepts of TypeScript functions easily, but if we have more complicated types, we may spend a significant amount of time typing everything.
The community maintains plenty of high-quality open source typings commonly used in TypeScript, called Definitely Typed, which can help us simplify and speed up the typing we need to use.
I hope this article has been helpful so you better understand the TypeScript landscape around passing functions as arguments to other functions.
Callbacks typically rely on this method, so you’ll often see heavy use of callbacks in any mature TypeScript codebase. Happy coding!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.