HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

27 Typing functions

This chapter explores static typing for functions in TypeScript.

Icon “reading”in this chapter, “function” means “function or method or constructor”

In this chapter, most things that are said about functions (especially w.r.t. parameter handling), also apply to methods and constructors.

27.1 Defining statically typed functions

27.1.1 Function declarations

This is an example of a function declaration in TypeScript:

function repeat1(str: string, times: number): string { // (A)
  return str.repeat(times);
}
assert.equal(
  repeat1('*', 5), '*****'
);

27.1.2 Arrow functions

The arrow function version of repeat1() looks as follows:

const repeat2 = (str: string, times: number): string => {
  return str.repeat(times);
};

Arrow functions can also have expression bodies:

const repeat3 = (str: string, times: number): string =>
  str.repeat(times);

27.2 Types for functions

27.2.1 Function type signatures

We can define types for functions via function type signatures:

type Repeat = (str: string, times: number) => string;

The name of this function type is Repeat. It has:

Which functions are assignable to this type? At least those that have the same parameter types and return type. But some others are assignable too. We’ll see which ones, when we explore the rules for assignability later in this chapter.

27.2.2 Interfaces with call signatures

We can also use interfaces to define function types:

interface Repeat {
  (str: string, times: number): string; // (A)
}

Note:

On one hand, interfaces are more verbose. On the other hand, they let us specify properties of functions (which is rare, but does happen):

interface Incrementor1 {
  (x: number): number;
  increment: number;
}

We can also specify properties via an intersection type (&) of a function signature type and an object literal type:

type Incrementor2 =
  & ((x: number) => number)
  & { increment: number }
;

27.2.3 Checking if a callable value matches a function type

As an example, consider this scenario: A library exports the following function type.

type StringPredicate = (str: string) => boolean;

We want to define a function whose type is compatible with StringPredicate. And we want to check immediately if that’s indeed the case (vs. finding out later when we use it for the first time).

27.2.3.1 Checking arrow functions

If we declare a variable via const, we can perform the check via a type annotation:

const pred1: StringPredicate = (str) => str.length > 0;

Note that we don’t need to specify the type of parameter str because TypeScript can use StringPredicate to infer it.

27.2.3.2 Checking function declarations (simple)

Checking function declarations is more complicated. Consider the following function declaration:

function pred2(str: string): boolean {
  return str.length > 0;
}

These are two built-in ways in which we can check assignability:

const pred2ImplementsStringPredicate: StringPredicate = pred2;
pred2 satisfies StringPredicate;

satisfies is explained in “The satisfies operator” (§29).

The following two checks require the library asserttt:

assertType<StringPredicate>(pred2);
type _ = Assert<Assignable<
  StringPredicate,
  typeof pred2
>>;

Note that all checks but the last one produce unnecessary JavaScript code.

27.2.3.3 Checking function declarations (extravagant)

The following solution is something you’d usually not do – so no worries if you don’t fully understand it. But it nicely demonstrates several advanced features:

function pred3(
  ...[str]: Parameters<StringPredicate>
): ReturnType<StringPredicate>
{
  return str.length > 0;
}

The built-in utility types Parameters<> and ReturnType<> are explained in “Extracting parts of function types via infer” (§35.3.1).

27.3 Parameters

27.3.1 When do parameters have to be type-annotated?

Recap: If --noImplicitAny is switched on (--strict switches it on), the type of each parameter must either be inferrable or explicitly specified.

In the following example, TypeScript can’t infer the type of str and we must specify it:

function twice(str: string) {
  return str + str;
}

In line A, TypeScript can use the type StringMapFunction to infer the type of str and we don’t need to add a type annotation:

type StringMapFunction = (str: string) => string;
const twice: StringMapFunction = (str) => str + str; // (A)

Here, TypeScript can use the type of .map() to infer the type of str:

assert.deepEqual(
  ['a', 'b', 'c'].map((str) => str + str),
  ['aa', 'bb', 'cc']
);

This is the type of .map():

interface Array<T> {
  map<U>(
    callbackfn: (value: T, index: number, array: T[]) => U,
    thisArg?: any
  ): U[];
  // ···
}

27.3.2 Optional parameters

In this section, we look at several ways in which we can allow parameters to be omitted.

27.3.2.1 Optional parameter: str?: string

If we put a question mark after the name of a parameter, that parameter becomes optional and can be omitted when calling the function:

function trim1(str?: string): string {
  // Internal type of str:
  assertType<string | undefined>(str);

  if (str === undefined) {
    return '';
  }
  return str.trim();
}

// External type of trim1:
type _ = Assert<Equal<
  typeof trim1,
  (str?: string | undefined) => string
>>;

This is how trim1() can be invoked:

assert.equal(
  trim1('\n  abc \t'), 'abc'
);

assert.equal(
  trim1(), ''
);

// `undefined` is equivalent to omitting the parameter
assert.equal(
  trim1(undefined), ''
);

As an aside, the following two types are equal (Equal<> is a strict check) because the optional modifier (?) implies the type undefined:

type _ = Assert<Equal<
  (str?: string | undefined) => string,
  (str?: string) => string
>>;
27.3.2.2 Union type: str: string | undefined

Externally, parameter str of trim1() has the type string | undefined. Therefore, trim1() is mostly equivalent to the following function.

function trim2(str: string | undefined): string {
  // Internal type of str:
  assertType<string | undefined>(str);

  if (str === undefined) {
    return '';
  }
  return str.trim();
}

// External type of trim2:
type _ = Assert<Equal<
  typeof trim2,
  (str: string | undefined) => string
>>;

The only way in which trim2() is different from trim1() is that the parameter can’t be omitted in function calls (line A). In other words: We must be explicit when omitting a parameter whose type is T|undefined.

assert.equal(
  trim2('\n  abc \t'), 'abc'
);

// @ts-expect-error: Expected 1 arguments, but got 0.
trim2(); // (A)

assert.equal(
  trim2(undefined), '' // OK!
);
27.3.2.3 Parameter default value: str = ''

If we specify a parameter default value for str, we don’t need to provide a type annotation because TypeScript can infer the type:

function trim3(str = ''): string {
  // Internal type of str:
  assertType<string>(str);

  return str.trim();
}

// External type of trim3:
type _ = Assert<Equal<
  typeof trim3,
  (str?: string | undefined) => string
>>;

The internal type of str is string because the default value ensures that it is never undefined.

Let’s invoke trim3():

assert.equal(
  trim3('\n  abc \t'), 'abc');

// Omitting the parameter triggers the parameter default value:
assert.equal(
  trim3(), '');

// `undefined` is allowed and triggers the parameter default value:
assert.equal(
  trim3(undefined), '');
27.3.2.4 Parameter default value plus type annotation

We can also specify both a type and a default value:

function trim4(str: string = ''): string {
  return str.trim();
}

27.3.3 Rest parameters

A rest parameter collects all remaining parameters in an Array. Therefore, its static type is an Array or a tuple.

27.3.3.1 Rest parameters with Array types

In the following example, the rest parameter parts has an Array type:

function join(separator: string, ...parts: Array<string>) {
  return parts.join(separator);
}
assert.equal(
  join('-', 'state', 'of', 'the', 'art'),
  'state-of-the-art');
27.3.3.2 Rest parameters with tuple types

The next example demonstrates two features:

function repeat1(...[str, times]: [string, number]): string {
  return str.repeat(times);
}

repeat1() is equivalent to the following function:

function repeat2(str: string, times: number): string {
  return str.repeat(times);
}

27.3.4 Named parameters

Named parameters are a popular pattern in JavaScript where an object literal is used to give each parameter a name. That looks as follows:

assert.equal(
  padStart({str: '7', len: 3, fillStr: '0'}),
  '007'
);

In plain JavaScript, functions can use destructuring to access named parameter values. Alas, in TypeScript, we additionally have to specify a type for the object literal and that leads to redundancies:

function padStart(
  { str, len, fillStr = ' ' } // (A)
  : { str: string, len: number, fillStr: string } // (B)
): string {
  return str.padStart(len, fillStr);
}

Note that the destructuring (incl. the default value for fillStr) all happens in line A, while line B is exclusively about TypeScript.

We can also define a separate type instead of the inlined object literal type that we used in line B:

function padStart(
  { str, len, fillStr = ' ' }: PadStartArgs
): string {
  return str.padStart(len, fillStr);
}
type PadStartArgs = {
  str: string,
  len: number,
  fillStr: string,
};

An upside is that the function declaration is less cluttered. A downside that we have to look elsewhere to see the types.

27.3.5 this as a parameter (advanced)

Each ordinary function always has the implicit parameter this – which enables it to be used as a method in objects. Sometimes we need to specify a type for this. There is TypeScript-only syntax for this use case: One of the parameters of an ordinary function can have the name this. Such a parameter only exists at compile time and disappears at runtime.

As an example, consider the following interface for DOM event sources (in a slightly simplified version):

interface EventSource {
  addEventListener(
    type: string,
    listener: (this: EventSource, ev: Event) => any,
    options?: boolean | AddEventListenerOptions
  ): void;
  // ···
}

The this of the callback listener is always an instance of EventSource.

The next example demonstrates that TypeScript uses the type information provided by the this parameter to check the first argument of .call() (line A and line B):

function toIsoString(this: Date): string {
    return this.toISOString();
}

// @ts-expect-error: Argument of type 'string' is not assignable to
// parameter of type 'Date'.
assert.throws(() => toIsoString.call('abc')); // (A) error

toIsoString.call(new Date()); // (B) OK

Additionally, we can’t invoke toIsoString() as a method of an object obj because then its receiver isn’t an instance of Date:

const obj = { toIsoString };
// @ts-expect-error: The 'this' context of type
// '{ toIsoString: (this: Date) => string; }' is not assignable to
// method's 'this' of type 'Date'.
assert.throws(() => obj.toIsoString()); // error
obj.toIsoString.call(new Date()); // OK

27.4 Return type never: functions that don’t return

never also serves as a marker for functions that never return – e.g.:

function throwError(message: string): never {
  throw new Error(message);
}
function infiniteLoop(): never {
  while (true) {}
}

TypeScript’s type inference takes such functions into consideration. For example, the inferred return type of returnStringIfTrue() is string because we invoke throwError() in line A.

function returnStringIfTrue(flag: boolean) {
  if (flag) {
    return 'abc';
  }
  throwError('Flag must be true'); // (A)
}
type _ = Assert<Equal<
  ReturnType<typeof returnStringIfTrue>,
  string
>>;

If we omit line A then we get an error and the inferred return type is 'abc' | undefined:

// @ts-expect-error: Not all code paths return a value.
function returnStringIfTrue(flag: boolean) {
  if (flag) {
    return 'abc';
  }
}
type _ = Assert<Equal<
  ReturnType<typeof returnStringIfTrue>,
  'abc' | undefined
>>;

27.4.1 Reasons against the return type never | T

In principle we could use the type never | T for a function that, in some cases, throws an exception and does not return normally. However there are two reasons against doing that:

27.4.2 The return type never in @types/node

In Node.js, the following functions have the return type never:

27.5 Overloading (advanced)

Sometimes a single type signature does not adequately describe how a function works.

27.5.1 Overloading function declarations

Consider function getFullName() which we are calling in the following example (line A and line B):

interface Customer {
  id: string;
  fullName: string;
}
const jane = {id: '1234', fullName: 'Jane Bond'};
const lars = {id: '5678', fullName: 'Lars Croft'};
const idToCustomer = new Map<string, Customer>([
  ['1234', jane],
  ['5678', lars],
]);

assert.equal(
  getFullName(idToCustomer, '1234'), 'Jane Bond' // (A)
);
assert.equal(
  getFullName(lars), 'Lars Croft' // (B)
);

How would we implement getFullName()? The following implementation works for the two function calls in the previous example:

function getFullName(
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string {
  if (customerOrMap instanceof Map) {
    if (id === undefined) throw new Error();
    const customer = customerOrMap.get(id);
    if (customer === undefined) {
      throw new Error('Unknown ID: ' + id);
    }
    customerOrMap = customer;
  } else {
    if (id !== undefined) throw new Error();
  }
  return customerOrMap.fullName;
}

However, with this type signature, function calls are legal at compile time that produce runtime errors:

assert.throws(() => getFullName(idToCustomer)); // missing ID
assert.throws(() => getFullName(lars, '5678')); // ID not allowed

The following code fixes these issues:

function getFullName(customer: Customer): string; // (A)
function getFullName( // (B)
  map: Map<string, Customer>, id: string
): string;
function getFullName( // (C)
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string {
  // ···
}

// @ts-expect-error: Argument of type 'Map<string, Customer>' is not
// assignable to parameter of type 'Customer'.
getFullName(idToCustomer); // missing ID

// @ts-expect-error: Argument of type '{ id: string; fullName: string; }'
// is not assignable to parameter of type 'Map<string, Customer>'.
// [...]
getFullName(lars, '5678'); // ID not allowed

What is going on here? The type signature of getFullName() is overloaded:

My advice is to only use overloading when it can’t be avoided. One alternative is to split an overloaded function into multiple functions with different names – for example:

27.5.2 Overloading functions via a union of tuple types

We can also overload a function via a union of tuple types (line A):

interface Customer {
  id: string;
  fullName: string;
}
function getFullName(
  ...args: // (A)
    | [customer: Customer]
    | [map: Map<string, Customer>, id: string]
): string {
  if (args.length === 2) {
    // Type is narrowed:
    assertType<
      [map: Map<string, Customer>, id: string]
    >(args);
    const [map, id] = args;
    const customer = map.get(id);
    if (customer === undefined) {
      throw new Error('Unknown ID: ' + id);
    }
    return customer.fullName;
  } else {
    const [customer] = args;
    return customer.fullName;
  }
}

Keep in mind that this kind of overloading only works if the return type is the same for all cases.

Note the labels for the elements of the tuple types:

[ customer: Customer ]
[ map: Map<string, Customer>, id: string ]

These labels are optional and mostly ignored, but may show up in auto-completions and type hints. In other words: They work more like comments. The previous two types are equivalent to:

[ Customer ]
[ Map<string, Customer>, string ]

For more information, see “Labeled tuple elements” (§37.1.3).

27.5.3 Overloading functions via interfaces

In interfaces, we can have multiple, different call signatures. That enables us to use the interface GetFullName for overloading in the following example:

interface Customer {
  id: string;
  fullName: string;
}

interface GetFullName {
  (customerOrMap: Customer): string;
  (customerOrMap: Map<string, Customer>, id: string): string;
}

const getFullName: GetFullName = (
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string => {
  if (customerOrMap instanceof Map) {
    if (id === undefined) throw new Error();
    const customer = customerOrMap.get(id);
    if (customer === undefined) {
      throw new Error('Unknown ID: ' + id);
    }
    customerOrMap = customer;
  } else {
    if (id !== undefined) throw new Error();
  }
  return customerOrMap.fullName;
}

27.5.4 Overloading on string parameters (event handling etc.)

In the next example, we overload and use string literal types (such as 'click'). That allows us to change the type of parameter listener depending on the value of parameter type:

function addEventListener(
  elem: HTMLElement, type: 'click',
  listener: (event: MouseEvent) => void
): void;
function addEventListener(
  elem: HTMLElement, type: 'keypress',
  listener: (event: KeyboardEvent) => void
): void;
function addEventListener( // (A)
  elem: HTMLElement, type: string,
  listener: (event: any) => void
): void {
  elem.addEventListener(type, listener); // (B)
}

In this case, it is relatively difficult to get the types of the implementation (starting in line A) right, so that the statement in the body (line B) works. As a last resort, we used the type any for parameter event of listener.

27.5.5 Overloading methods

27.5.5.1 Overloading concrete methods

The next example demonstrates overloading of methods: Method .add() is overloaded.

class StringBuilder {
  #data = '';

  add(num: number): this;
  add(bool: boolean): this;
  add(str: string): this;
  add(value: any): this {
    this.#data += String(value);
    return this;
  }

  toString() {
    return this.#data;
  }
}

const sb = new StringBuilder();
sb
  .add('I can see ')
  .add(3)
  .add(' monkeys!')
;
assert.equal(
  sb.toString(), 'I can see 3 monkeys!'
)
27.5.5.2 Overloading interface methods

The type definition for Array.from() is an example of an overloaded interface method:

interface ArrayConstructor {
  from<T>(arrayLike: ArrayLike<T>): T[];
  from<T, U>(
    arrayLike: ArrayLike<T>,
    mapfn: (v: T, k: number) => U,
    thisArg?: any
  ): U[];
}

27.6 Assignability (advanced)

In this section we look at the type compatibility rules for assignability: Can functions of type Src be transferred to storage locations (variables, object properties, parameters, etc.) of type Trg?

Understanding assignability helps us answer questions such as:

27.6.1 The rules for assignability

In this subsection, we examine general rules for assignability (including the rules for functions). In the next subsection, we explore what those rules mean for functions.

A type Src is assignable to a type Trg if one of the following conditions is true:

27.6.2 Consequences of the assignment rules for functions

In this subsection, we look at what the assignment rules mean for the following two functions targetFunc and sourceFunc:

const targetFunc: Trg = sourceFunc;
27.6.2.1 Types of parameters and results

Example:

const trg1: (x: RegExp) => Object = (x: Object) => /abc/;

The following example demonstrates that if the target return type is void, then the source return type doesn’t matter. Why is that? void results are always ignored in TypeScript.

const trg2: () => void = () => new Date();
27.6.2.2 Numbers of parameters

The source must not have more parameters than the target:

// @ts-expect-error: Type '(x: string) => string' is not assignable to
// type '() => string'.
const trg3: () => string = (x: string) => 'abc';

The source can have fewer parameters than the target:

const trg4: (x: string) => string = () => 'abc';

Why is that? The target specifies the expectations for the source: It must accept the parameter x. Which it does (but it ignores it). This permissiveness enables:

['a', 'b'].map(x => x + x)

The callback for .map() only has one of the three parameters that are mentioned in the type signature of .map():

map<U>(
  callback: (value: T, index: number, array: T[]) => U,
  thisArg?: any
): U[];

27.7 Further reading and sources of this chapter