bastianplsfix

Function types

A function's type is its parameter list and its return type, written with an arrow: (text: string) => boolean. That is the whole idea, and two other spellings exist for the cases it cannot cover. The interesting part is the rule that decides which functions satisfy a given type: it is looser than people expect about the number of parameters and the return value, and stricter than people expect about parameter types, with one exception that depends on whether you wrote a method or a property.

The functions page is the entry for writing functions, and the parameters and arguments page for what happens to arguments at run time. This one is about the type.

Create programs/function-types.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

a parameter list and a return type

Deno.test("a parameter list and a return type", () => {
type Repeat = (text: string, times: number) => string;

const repeat: Repeat = (text, times) => text.repeat(times);

assertStrictEquals(repeat("*", 3), "***");
assertStrictEquals(repeat("ab", 2), "abab");
});
Check programs/function-types.test.ts
running 1 test from ./programs/function-types.test.ts
a parameter list and a return type ... ok (188µs)

ok | 1 passed | 0 failed (1ms)

Two parameters and a return type after the arrow, where the parameter names are documentation: they show up in a tooltip and are ignored when the checker compares two function types. The return type cannot be omitted here, unlike on a function declaration where it is inferred, because a type has to say what it is. And the payoff is on the implementation line: (text, times) needs no annotations, because the annotation on repeat supplies them. Naming a function type once and letting every implementation infer from it is the main reason to write one.

an interface can hold a call signature, and properties

Deno.test("an interface can hold a call signature, and properties", () => {
type Repeat = (text: string, times: number) => string;

interface RepeatInterface {
(text: string, times: number): string;
}

interface Counter {
(step: number): number;
reset(): void;
}

type CounterAsIntersection = ((step: number) => number) & { reset(): void };

function createCounter(): Counter {
let total = 0;
return Object.assign(
(step: number): number => {
total += step;
return total;
},
{
reset() {
total = 0;
},
},
);
}

const repeat: Repeat = (text, times) => text.repeat(times);
const alsoRepeat: RepeatInterface = repeat;
assertStrictEquals(alsoRepeat("-", 2), "--");

const counter: Counter = createCounter();
const asIntersection: CounterAsIntersection = counter;

assertStrictEquals(counter(2), 2);
assertStrictEquals(counter(3), 5);
counter.reset();
assertStrictEquals(asIntersection(1), 1);
});
an interface can hold a call signature, and properties ... ok (53µs)

A call signature is a method signature without a name, the member the object types page listed in its five and left untaught. An interface with only a call signature is the same type as the arrow form, more verbose and no different, as the repeat round trip shows. It earns its keep when the function also has properties: Counter is callable and has a reset method, which the arrow syntax cannot express, and the intersection version says the same thing and reads about as well, so pick either. Functions with properties are rarer than they used to be, and they are still how you describe a great deal of existing JavaScript, including much of the standard library; Object.assign is how createCounter builds one without a cast, since the result type is exactly that intersection.

four ways to say a function matches a type

Deno.test("four ways to say a function matches a type", () => {
type StringPredicate = (text: string) => boolean;

function isNotEmpty(text: string): boolean {
return text.length > 0;
}

const checkedByAnnotation: StringPredicate = isNotEmpty;
const checkedBySatisfies = isNotEmpty satisfies StringPredicate;

assert(checkedByAnnotation("a"));
assert(checkedBySatisfies("a"));

const inline: StringPredicate = (text) => text.length > 0;
assert(inline("a"));
});
four ways to say a function matches a type ... ok (25µs)

If a library gives you a function type and you want to know now, rather than at the first call site, that your function fits it, there are four ways to ask. An annotated variable works and emits a variable declaration you did not need. satisfies checks the same thing, keeps the function's own type, and emits nothing extra beyond the binding, the cheapest for a declaration, as the object types page argues for values generally. A type-level assertion helper emits nothing at all, worth having in a project that does this often. Or write the function as an annotated arrow in the first place, which is the last line and the one to reach for, because there is nothing to check afterwards if the annotation was there from the start.

the question mark is about the call site

Deno.test("the question mark is about the call site", () => {
function trimOptional(text?: string): string {
if (text === undefined) return "";
return text.trim();
}

function trimUnion(text: string | undefined): string {
if (text === undefined) return "";
return text.trim();
}

function trimDefault(text = ""): string {
return text.trim();
}

assertStrictEquals(trimOptional(), "");
assertStrictEquals(trimOptional(undefined), "");
assertStrictEquals(trimOptional(" a "), "a");

// @ts-expect-error: Expected 1 arguments, but got 0.
assertStrictEquals(trimUnion(), "");
assertStrictEquals(trimUnion(undefined), "");

assertStrictEquals(trimDefault(), "");
assertStrictEquals(trimDefault(undefined), "");
});
the question mark is about the call site ... ok (27µs)

Three functions whose bodies handle the same two cases, and three different types. text?: string has the external type (text?: string) => string, identical to spelling the union out after the question mark, and inside the body text is string | undefined. text: string | undefined says the same thing about the value and a different thing about the call: the argument is required, so omitting it is the shielded TS2554 while passing undefined explicitly is fine, a distinction worth having on purpose, because a reader who sees trimUnion(undefined) knows the option exists and was switched off, the same split the object types page drew for properties. And text = "" is the third shape, externally optional and internally string, so the body never sees undefined at all; the parameters and arguments page has the run-time half, including the .length difference that gives the erasure away.

the parts of a function type have names

Deno.test("the parts of a function type have names", () => {
type Repeat = (text: string, times: number) => string;
type StringPredicate = (text: string) => boolean;

const repeat: Repeat = (text, times) => text.repeat(times);

const parameters: Parameters<Repeat> = ["ab", 2];
const returned: ReturnType<Repeat> = repeat(...parameters);

assertStrictEquals(returned, "abab");

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

assert(alsoPredicate("a"));
});
the parts of a function type have names ... ok (29µs)

Parameters<F> is the tuple of a function type's parameters and ReturnType<F> is its return type, both built in and both useful when you are wiring something to a function type you do not own; the machinery behind them is generics and infer, ground this series has not covered yet. The tuple is a real tuple, so it spreads into a call, which is the tidiest use of the pair: take a function's parameters, keep them, apply them later. alsoPredicate is the curiosity worth seeing once and not adopting, a rest parameter typed as the extracted tuple and destructured back into a name, which declares a function whose signature is guaranteed to match another type and costs more to read than the annotated arrow that does the same job.

never means this call does not come back

Deno.test("never means this call does not come back", () => {
function fail(message: string): never {
throw new Error(message);
}

function requireString(value: unknown) {
if (typeof value === "string") {
return value;
}
fail(`not a string: ${String(value)}`);
}

const text: string = requireString("oak");

assertStrictEquals(text, "oak");
assertThrows(() => requireString(1), Error, "not a string: 1");
});
never means this call does not come back ... ok (444µs)

never as a return type says the function does not return: it throws, or it loops forever, and since never is the empty set, returns-a-value-from-the-empty-set is the only honest way to write that, which the any, unknown, never page explains from the other side. The payoff is in the inference. requireString has no return statement on its second path, and its inferred return type is string rather than string | undefined, as the annotated text line proves, because the checker knows the call to fail ends the function. So a helper that throws keeps its callers' types clean, and that is the reason to annotate never rather than leaving it inferred. Do not write never | T, which is the same type as T, since adding the empty set to a set changes nothing: a function that sometimes throws has the return type of the values it returns, and that is what exceptions are for.

and without it the missing return is silent

One path returns a string and the other falls off the end, and it compiles. Predict what the false branch produces:

Deno.test("and without it the missing return is silent", () => {
function maybeString(flag: boolean) {
if (flag) {
return "oak";
}
}

assertStrictEquals(maybeString(true), "oak");
assertStrictEquals(maybeString(false), "");
});
Check programs/function-types.test.ts
running 7 tests from ./programs/function-types.test.ts
...
and without it the missing return is silent ... FAILED (7ms)

ERRORS

and without it the missing return is silent => ./programs/function-types.test.ts:147:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- undefined
+ ""

FAILURES

and without it the missing return is silent => ./programs/function-types.test.ts:147:6

FAILED | 6 passed | 1 failed (9ms)

error: Test failed

Falling off the end is undefined, and the inferred return type says so: "oak" | undefined, with nothing reported, because noImplicitReturns is not part of strict and Deno leaves it off. Turn it on and the same function is TS7030, "Not all code paths return a value", which is worth having: the inferred | undefined is correct and rarely intentional, since a function that means to return nothing on one path usually means to return nothing on all of them, and one that does not has a bug. Correct the prediction and pin the inferred type:

Deno.test("and without it the missing return is silent", () => {
function maybeString(flag: boolean) {
if (flag) {
return "oak";
}
}

const value: "oak" | undefined = maybeString(false);

assertStrictEquals(value, undefined);
assertStrictEquals(maybeString(true), "oak");
});
and without it the missing return is silent ... ok (24µs)

a source may take fewer parameters, never more

Deno.test("a source may take fewer parameters, never more", () => {
type NoArguments = () => string;
type OneArgument = (text: string) => string;

const ignoring: OneArgument = () => "oak";

// @ts-expect-error: Type '(text: string) => string' is not assignable to type '() => string'.
const demanding: NoArguments = (text: string) => text;

assertStrictEquals(ignoring("anything"), "oak");
assertStrictEquals(demanding() as unknown, undefined);

assertEquals(["a", "b"].map((item) => item + item), ["aa", "bb"]);
});
a source may take fewer parameters, never more ... ok (133µs)

The first rule of assignability for functions, and it is the permissive one. A function that ignores a parameter can stand in for one that receives it, because the caller will pass it and the function will not care. A function that demands a parameter cannot stand in for one that will not be given it, and the shielded line shows why: demanding() hands back undefined from a function declared to return string. This rule is why callbacks are bearable in JavaScript, because map passes three arguments and almost every callback takes one, the fact the transforming arrays page turned into the map(parseInt) bug.

a void return type ignores whatever the source returns

A function returning a Date satisfies () => void. Predict what the void-typed result actually holds:

Deno.test("a void return type ignores whatever the source returns", () => {
type Ignored = () => void;

const returningSomething: Ignored = () => new Date(0);

const result: void = returningSomething();

assertStrictEquals(result as unknown, undefined);
});
Check programs/function-types.test.ts
running 9 tests from ./programs/function-types.test.ts
...
a void return type ignores whatever the source returns ... FAILED (7ms)

ERRORS

a void return type ignores whatever the source returns => ./programs/function-types.test.ts:177:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 1970-01-01T00:00:00.000Z
+ undefined

FAILURES

a void return type ignores whatever the source returns => ./programs/function-types.test.ts:177:6

FAILED | 8 passed | 1 failed (9ms)

error: Test failed

The Date is right there, alive inside a void. The second rule looks like a hole until you see what it is for: a caller who declared they want nothing back cannot be hurt by getting something, so a function returning a Date satisfies () => void, and void does not mean the value is gone, only that the type is a promise nobody will look. That is why array.forEach(() => list.push(x)) compiles even though push returns a number. Correct the prediction:

Deno.test("a void return type ignores whatever the source returns", () => {
type Ignored = () => void;

const returningSomething: Ignored = () => new Date(0);

const result: void = returningSomething();

assert(result as unknown instanceof Date);
assertStrictEquals((result as unknown as Date).getTime(), 0);
});
a void return type ignores whatever the source returns ... ok (17µs)

parameter types go the other way round

The third rule reads backwards the first few times. Put the refusal in a scratch file programs/assignability.ts, spelled as a plain function type and as a property:

type Wide = { kind: string };
type Narrow = { kind: string; extra: boolean };

const acceptsNarrow = (_value: Narrow) => {};

type TakesWide = (value: Wide) => void;
export const asFunction: TakesWide = acceptsNarrow;

type WithProperty = { handle: (value: Wide) => void };
export const asProperty: WithProperty = { handle: acceptsNarrow };
Check programs/assignability.ts
TS2322 [ERROR]: Type '(_value: Narrow) => void' is not assignable to type 'TakesWide'.
Types of parameters '_value' and 'value' are incompatible.
Property 'extra' is missing in type 'Wide' but required in type 'Narrow'.
export const asFunction: TakesWide = acceptsNarrow;
~~~~~~~~~~
at file:///programs/assignability.ts:7:14

'extra' is declared here.
type Narrow = { kind: string; extra: boolean };
~~~~~
at file:///programs/assignability.ts:2:31

TS2322 [ERROR]: Type '(_value: Narrow) => void' is not assignable to type '(value: Wide) => void'.
Types of parameters '_value' and 'value' are incompatible.
Property 'extra' is missing in type 'Wide' but required in type 'Narrow'.
export const asProperty: WithProperty = { handle: acceptsNarrow };
~~~~~~
at file:///programs/assignability.ts:10:43

error: Type checking failed.

Read it as a promise to a caller. TakesWide promises that any Wide may be passed; a function that requires extra cannot keep that promise, and the message says exactly which property would be missing. A function whose parameter type is wider can satisfy a type whose parameter is narrower, and not the other way round. Delete the scratch file, and pin both directions, with the runtime showing what the refusal was protecting against:

Deno.test("parameter types go the other way round", () => {
type Wide = { kind: string };
type Narrow = { kind: string; extra: boolean };

const acceptsWide = (value: Wide) => value.kind;
const acceptsNarrow = (value: Narrow) => value.kind + String(value.extra);

type TakesNarrow = (value: Narrow) => string;
const fine: TakesNarrow = acceptsWide;

type TakesWide = (value: Wide) => string;
// @ts-expect-error: Type '(value: Narrow) => string' is not assignable to type 'TakesWide'.
const broken: TakesWide = acceptsNarrow;

assertStrictEquals(fine({ kind: "a", extra: true }), "a");
assertStrictEquals(broken({ kind: "a" }) as unknown, "aundefined");
});
parameter types go the other way round ... ok (19µs)

fine asks for less than it will be given and keeps the promise easily. The shielded broken reads extra off a value that has none, and "aundefined" is the quiet corruption the rule exists to stop. So the rule to remember when you write a callback type: take the widest parameter you can use, because every implementation that needs less will fit, and none that needs more will sneak in.

and a method holds its parameters to a lower standard

Deno.test("and a method holds its parameters to a lower standard", () => {
type Wide = { kind: string };
type Narrow = { kind: string; extra: boolean };

const handler = (value: Narrow) => value.kind + String(value.extra);

type WithMethod = { handle(value: Wide): string };
type WithProperty = { handle: (value: Wide) => string };

const asMethod: WithMethod = { handle: handler };

// @ts-expect-error: Type '(value: Narrow) => string' is not assignable to type '(value: Wide) => string'.
const asProperty: WithProperty = { handle: handler };

assertStrictEquals(asMethod.handle({ kind: "a" }), "aundefined");
assertStrictEquals(asProperty.handle({ kind: "a" }), "aundefined");
});
and a method holds its parameters to a lower standard ... ok (21µs)

Two object types differing only in how the member is spelled, and the same handler satisfies the first with no shield while the second refuses it. The object types page says a method signature and a function-valued property are the same type, and that is true of assigning a value of one to the other; this is the footnote, because they hold their own members to different standards. strictFunctionTypes, which arrives with strict, applies the previous step's rule to the property form and leaves method signatures bivariant, accepting parameters in either direction, and the reason is history rather than principle: methods on built-in types would fail the strict rule in enough places that turning it on for them was not viable. Note the first pin, "aundefined" out of the version that compiled without complaint. Which turns a stylistic choice into a real one: if the parameter types of a callback matter, write it as a property.

name the function type once and let implementations infer

Deno.test("name the function type once and let implementations infer", () => {
type Formatter = (value: number) => string;

const formatters: Record<string, Formatter> = {
plain: (value) => String(value),
fixed: (value) => value.toFixed(2),
};

assertStrictEquals(formatters.plain(3), "3");
assertStrictEquals(formatters.fixed(3), "3.00");
});
name the function type once and let implementations infer ... ok (198µs)

Two implementations, no annotations inside either, and one place to change if the signature moves. This is the shape most function types should be used in.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/function-types.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 12 tests from ./programs/function-types.test.ts
a parameter list and a return type ... ok (177µs)
an interface can hold a call signature, and properties ... ok (35µs)
four ways to say a function matches a type ... ok (27µs)
the question mark is about the call site ... ok (27µs)
the parts of a function type have names ... ok (27µs)
never means this call does not come back ... ok (269µs)
and without it the missing return is silent ... ok (19µs)
a source may take fewer parameters, never more ... ok (135µs)
a void return type ignores whatever the source returns ... ok (20µs)
parameter types go the other way round ... ok (21µs)
and a method holds its parameters to a lower standard ... ok (25µs)
name the function type once and let implementations infer ... ok (30µs)
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 662 passed | 0 failed (1s)

Twelve tests, and the practice is short. Name the function type once and let the implementations infer. Use satisfies to check an existing declaration and an annotation when you are writing the function anyway, without adding a variable whose only purpose is to hold the check. Take the widest parameter you can use and return the narrowest you can promise, because both directions of the assignability rule are asking for the same discipline. Write a callback type as a property rather than a method when its parameter types matter, since that is the spelling the checker actually enforces. Annotate never on a function that always throws, the difference between a helper that keeps its callers' return types clean and one that contaminates them with undefined. And turn on noImplicitReturns in anything new, because the silent | undefined on a function with one missing return is almost never what somebody meant.