Typing functions in TypeScript

[2020-04-12] dev, javascript, typescript
(Ad, please don’t block)

This blog post explores static typing for functions in TypeScript.

For the remainder of this post, most things that are said about functions (especially w.r.t. parameter handling), also apply to methods.

Defining statically typed functions  

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),
  '*****');
  • Parameters: If the compiler option --noImplicitAny is on (which it is if --strict is on), then the type of each parameter must be either inferrable or explicitly specified. (We’ll take a closer look at inference later.) In this case, no inference is possible, which is why str and times have type annotations.

  • Return value: By default, the return type of functions is inferred. That is usually good enough. In this case, we opted to explicitly specify that repeat1() has the return type string (last type annotation in line A).

Arrow functions  

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

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

In this case, we can also use an expression body:

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

Types of functions  

Function type signatures  

We can define types for functions via function type signatures:

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

The name of this type of function is Repeat. It matches all functions that:

  • Have two parameters whose types are string and number. We need to name parameters in function type signatures, but the names are ignored when checking if two function types are compatible.
  • Have the return type string. Note that this time, the type is separated by an arrow and can’t be omitted.

(We’ll cover the precise compatibility rules later.)

Interfaces with call signatures  

We can also use interfaces to define function types:

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

Note:

  • The member in line A is a call signature. It looks similar to a method signature, but doesn’t have a name.
  • The type of the result is separated by a colon, not by an arrow.

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 a type intersection (&) of a function signature type and an object literal type:

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

Checking if a value matches a 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).

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.

Checking function declarations (simple)  

Checking function declarations is more complicated:

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

// Assign the function to a type-annotated variable
const pred2ImplementsStringPredicate: StringPredicate = pred2;

Checking function declarations (extravagant)  

The following solution is slightly over the top (i.e., don’t worry if you don’t fully understand it), but it demonstrates several advanced features:

function pred3(...[str]: Parameters<StringPredicate>)
  : ReturnType<StringPredicate> {
    return str.length > 0;
  }
  • Parameters: We use Parameters<> to extract a tuple with the parameter types. The three dots declare a rest parameter, which collects all parameters in a tuple/Array. [str] destructures that tuple. (More on rest parameters later in this post.)

  • Return value: We use ReturnType<> to extract the return type.

Parameters  

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[];
  // ···
}

Optional parameters  

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

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:
  // %inferred-type: string | undefined
  str;

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

// External type of trim1:
// %inferred-type: (str?: string | undefined) => string
trim1;

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), '');

Type union: str: undefined|string  

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

function trim2(str: undefined|string): string {
  // Internal type of str:
  // %inferred-type: string | undefined
  str;

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

// External type of trim2:
// %inferred-type: (str: string | undefined) => string
trim2;

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 undefined|T.

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

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

assert.equal(
  trim2(undefined), ''); // OK!

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:
  // %inferred-type: string
  str;

  return str.trim();
}

// External type of trim2:
// %inferred-type: (str?: string) => string
trim3;

Note that 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` also triggers the parameter default value:
assert.equal(
  trim3(undefined), '');

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();
}

Rest parameters  

Rest parameters with Array types  

A rest parameter collects all remaining parameters in an Array. Therefore, its static type is usually an Array. In the following example, parts is a rest parameter:

function join(separator: string, ...parts: string[]) {
  return parts.join(separator);
}
assert.equal(
  join('-', 'state', 'of', 'the', 'art'),
  'state-of-the-art');

Rest parameters with tuple types  

The next example demonstrates two features:

  • We can use tuple types such as [string, number] for rest parameters.
  • We can destructure rest parameters (not just normal parameters).
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);
}

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)
  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.

It is possible to define a separate type instead of the inlined object literal type that we have used in line B. However, in most cases, I prefer not to do that because it slightly goes against the nature of parameters which are local and unique per function. If you prefer having less stuff in function heads, then that’s OK, too.

this as a parameter  

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-ignore: Argument of type '"abc"' is not assignable to
// parameter of type 'Date'. (2345)
assert.throws(() => toIsoString.call('abc')); // (A)

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-ignore: The 'this' context of type
// '{ toIsoString: (this: Date) => string; }' is not assignable to
// method's 'this' of type 'Date'. [...]
assert.throws(() => obj.toIsoString());
obj.toIsoString.call(new Date());

Overloading  

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

Overloading function declarations  

Consider function getFullName() which we are calling in the following example:

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(customerOrMap: Customer): string; // (A)
function getFullName( // (B)
  customerOrMap: Map<string, Customer>, id: string): string;
function getFullName( // (C)
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string {
  // ···
}

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

// @ts-ignore: 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 actual implementation starts in line C. It is the same as in the previous example.
  • In line A and line B there are the two type signatures (function heads without bodies) that can be used for getFullName() (the type signature of the actual implementation cannot be used). The type signature of getFullName() is overloaded.

My advice is to only use overloading when it can’t be avoided. For example, I’d have preferred to split getFullName() into two functions:

  • getFullName()
  • getFullNameViaMap()

Overloading 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 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;
}

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(elem: HTMLElement, type: string,  // (A)
  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 can always use the type any.

Overloading methods  

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!')

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[];
}
  • In the first signature, the return type is the same as the type of this.
  • In the second signature, the elements of the returned Array have the same type as the result of mapfn. This version of Array.from() is similar to Array.prototype.map().

Assignability  

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:

  • Given the function type signature of a formal parameter, which functions can be passed as actual parameters in function calls?
  • Given the function type signature of a property, which functions can be assigned to it?

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:

  • Src and Trg are identical types.
  • Src or Trg is the any type.
  • Src is a string literal type and Trg is the primitive type String.
  • Src is a union type and each constituent type of Src is assignable to Trg.
  • Src and Trg are function types and:
    • Trg has a rest parameter or the number of required parameters of Src is less than or equal to the total number of parameters of Trg.
    • For parameters that are present in both signatures, each parameter type in Trg is assignable to the corresponding parameter type in Src.
    • The result type of Trg is void or the result type of Src is assignable to the result type of Trg.
  • (Remaining conditions omitted.)

Consequences of the rules for functions  

Types of parameters and results  

  • Target parameter types must be assignable to corresponding source parameter types.
    • Why? Anything that the target accepts must also be accepted by the source.
  • The source result type must be assignable to target result type.
    • Why? Anything that the source returns must be compatible with the expectations set by the target.

Example:

// target = source
const f1: (x: RegExp) => Object = (x: Object) => /abc/;

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

const f2: () => void = () => new Date();

Numbers of parameters  

The source must not have more parameters than target:

// @ts-ignore: Type '(x: string) => string' is not assignable to
// type '() => string'. (2322)
const f4: () => string = (x: string) => 'abc';

The source can have fewer parameters than the target:

const f3: (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[];

Further reading and sources of this blog post