TypeScript Function Basics

Functions serve as the fundamental building blocks of any TypeScript application. While JavaScript provides the flexibility to define functions, TypeScript elevates this capability by introducing static typing for both parameters and return values. This addition allows developers to catch potential errors—such as passing a string where a number is expected—during the compilation phase rather than at runtime.
The most straightforward way to define a function in TypeScript is through a standard function declaration. You can explicitly specify the type for every parameter and the type that the function will return.

function add(a: number, b: number): number {
  return a + b;
}

In the example above, if you attempt to call add("1", 2), the TypeScript compiler will immediately flag an error.

While TypeScript is highly capable of type inference—meaning it can often guess the return type based on the return statement—it is a best practice to explicitly annotate return types, especially when building public APIs. Explicit annotations act as a form of documentation, making your code more readable and easier to maintain for other developers.

Function Expressions and Arrow Functions

TypeScript provides full support for both function expressions and arrow functions. Both patterns allow for precise type annotations.

const multiply = function (a: number, b: number): number {
  return a * b;
};

const divide = (a: number, b: number): number => a / b;

Arrow functions are particularly powerful when used as callbacks. One of their key characteristics is that they do not have their own this context, which makes them ideal for maintaining the lexical scope of the surrounding code.

Furthermore, TypeScript excels at contextual typing. When an arrow function is used in a context where the type is already known (such as within an array method), you often don't need to explicitly type the parameters:

const nums = [1, 2, 3];
const doubled = nums.map((n) => n * 2); // 'n' is automatically inferred as a number

Managing Parameters: Optional, Default, and Rest

TypeScript offers several ways to handle varying input requirements through specialized parameter types.

Optional Parameters

If a parameter is not strictly required for the function to operate, you can mark it as optional using the ? symbol. Note that optional parameters must always follow required parameters in the function signature.

function greet(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}

greet("Alice");           // Works
greet("Alice", "Dr.");    // Works

Default Parameters

Default parameters allow you to assign a fallback value if an argument is omitted or passed as undefined. This is a cleaner alternative to manual null checks inside the function body.

function createUser(name: string, age: number = 18): string {
  return `${name}(${age})`;
}

Rest Parameters

When you need to handle an indefinite number of arguments, use rest parameters. These are collected into an array, and the type definition must reflect this array structure. Rest parameters must always be the final parameter in the list.

function sum(...nums: number[]): number {
  return nums.reduce((total, n) => total + n, 0);
}

sum(1, 2, 3, 4); // Returns 10

Function Types and Callbacks

In complex applications, you often need to pass functions as arguments to other functions. To do this safely, you must define the function type. This can be achieved using Type Aliases or Interfaces.

// Using a Type Alias
type MathOperation = (a: number, b: number) => number;

const subtract: MathOperation = (a, b) => a - b;

// Using an Interface
interface Logger {
  (message: string): void;
}

The void type is specifically used to indicate that a function does not return a value. Interestingly, when a callback is typed to return void, TypeScript allows you to pass a function that actually returns a value, as the caller will simply ignore that return value.

function process(items: string[], callback: (item: string) => void): void {
  items.forEach(callback);
}

process(["a", "b"], (item) => console.log(item));

Function Overloading

Sometimes, a single function needs to behave differently depending on the types or number of arguments provided. In these cases, you can use function overloading. An overloaded function consists of multiple overload signatures followed by a single implementation signature.

function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
  return typeof value === "string" ? value.trim() : value.toFixed(2);
}

format("  hi  "); // Returns "hi"
format(3.14159);  // Returns "3.14"

It is crucial that the implementation signature is broad enough to be compatible with all preceding overload signatures. However, a word of caution: if you can achieve the same result using Union Types (e.g., string | number), you should prefer that over overloading to keep the code simple and maintainable.

Advanced Concepts: never and this

The never Type

The never type represents values that will never occur. In the context of functions, it is used for functions that never reach a return statement—for example, functions that always throw an error or enter an infinite loop.

function fail(message: string): never {
  throw new Error(message);
}

Explicit this Parameter

TypeScript allows you to explicitly type the this context within a function. This is incredibly useful when writing functions that will be attached to objects, ensuring that the function can safely access the properties of that object.

function say(this: { name: string }, greeting: string): string {
  return `${greeting}, ${this.name}`;
}

const user = { name: "Alice", say };
user.say("Hello"); // "Hello, Alice"

Professional Best Practices

To write high-quality, production-ready TypeScript, consider the following guidelines:

  • Be Explicit in Public APIs: Always annotate parameters and return types for functions intended for external use. This improves documentation and prevents accidental breaking changes.
  • Leverage Reusability: Use type aliases or interfaces to define complex function signatures once and reuse them throughout your codebase.
  • Avoid any: Using any bypasses the benefits of TypeScript. If you are unsure of a type, use unknown and perform type narrowing within the function.
  • Parameter Order Matters: Always place optional and default parameters at the end of your parameter list to avoid ambiguity.
  • Prefer Unions over Overloads: Only use function overloading when the logic is significantly different based on the input types. If a union type can describe the input and output, use it.

By mastering these fundamentals, you lay the groundwork for more advanced topics such as Generics, Asynchronous Functions, and Type Guards, enabling you to build robust and scalable software.