Functions are where most annotations in a codebase live, because parameters are the one thing TypeScript cannot infer. This lesson covers the syntax, the function type — which is what you annotate a callback with — and overloads, which are less often the right answer than people expect.
Parameters and returns
export function formatMoney(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}Parameters are annotated individually; the return type goes after the parameter list. The return type is optional — TypeScript will infer it — and lesson 3 covers when writing it is worth the keystrokes. Short version: pin it on anything exported.
The same applies to arrow functions and methods:
export const money = (amount: number): string => formatMoney(amount);Optional, default and rest parameters
A ? makes a parameter optional, and optional parameters must come last:
function greet(name: string, title?: string) {
return title ? `${title} ${name}` : name; // title is string | undefined
}A default value implies optional, and the type is inferred from the default:
export function pluralise(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}Note the difference between the two. With ?, the parameter's type includes
undefined and you handle it. With a default, it does not — the default has already
replaced undefined by the time the body runs. Prefer a default when there is a sensible
one; it removes a check.
Rest parameters take an array type:
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}The function type
To describe a function you are receiving rather than defining, you need a type for it:
(product: Product) => voidParameter list, fat arrow, return type. That is what a callback prop is annotated with, and it is the second-most common type in a React codebase after the domain objects:
interface Props {
product: Product;
onSelect: (product: Product) => void;
}The parameter names in a function type are documentation only — they need not match the implementation. What is checked is the count, the types and the order.
Two things about this type that catch people out.
Fewer parameters is allowed. A function taking no arguments is assignable to
(product: Product) => void:
<ProductCard product={product} onSelect={() => setOpen(true)} />That has to work, or arr.forEach(() => count++) would be an error. A caller
passing more information than the callback wants is harmless.
A void return accepts any return. Covered in
lesson 4 — the contract is that the caller will
ignore the value, not that there is none.
Contextual typing
When a function is written where its type is already known, the parameters need no annotation:
const subtotal = round2(items.reduce((sum, item) => sum + lineTotal(item), 0));sum and item are typed by reduce. The same happens with
event handlers, promise callbacks and anything passed to a typed parameter — which is why real
TypeScript has far fewer annotations than tutorials suggest.
It only works in that direction. Declare the function separately and the context is gone:
// This does not compile.
const add = (sum, item) => sum + lineTotal(item);
// ~~~
// Parameter 'sum' implicitly has an 'any' type.
items.reduce(add, 0);Call signatures and methods
A function type inside an object type can be written two ways:
interface Formatter {
transform(value: number): string; // method shorthand
format: (value: number) => string; // property with a function type
}Almost the same. The one real difference is variance under
strictFunctionTypes: method shorthand parameters are checked bivariantly (looser),
property-style ones contravariantly (stricter, and more correct). If that sentence means nothing
yet, the practical rule is that the property form catches more mistakes, and the method form exists
because changing it would break how the built-in library types are declared.
Angular's PipeTransform uses the method form, and the app implements it:
@Pipe({ name: 'money' })
export class MoneyPipe implements PipeTransform {
transform(value: number | null | undefined): string {
return formatMoney(value ?? 0);
}
}Note the parameter type. The pipe is called from a template where the value may not have arrived
yet, so it accepts the absent cases and decides what they mean — ?? 0 — rather than
making every template guard against it.
Overloads
When one function genuinely has two call shapes, you can declare several signatures over a single implementation:
function parseRange(days: number): [Date, Date];
function parseRange(from: string, to: string): [Date, Date];
function parseRange(a: number | string, b?: string): [Date, Date] {
// one implementation, handling both
}The first two lines are what callers see. The third is the implementation signature — it must be
compatible with all of them, and it is not callable. A caller cannot pass
(number, string) even though the implementation signature would allow it.
Overloads are less useful than they look, and there is usually a better option.
Prefer optional parameters when the difference is only whether an argument is
present. Two overloads that differ by one optional argument are two declarations doing what
? does in one.
Prefer a union when the difference is the argument's type but the return type is the same. The app's HTTP layer is a good example of what that looks like:
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
/** Send the bearer token. Public endpoints (the menu, guest checkout) do not need it. */
auth?: boolean;
signal?: AbortSignal;
}Five methods, one options object, no overloads. Every difference between the calls is an optional property rather than a separate signature.
Prefer a generic when the return type depends on the argument type. This is the case overloads are most often misused for — lesson 13 covers it, and it is almost always the better answer.
What is left for overloads is the genuine case: two call shapes whose return types differ and cannot be expressed by a type parameter. That is rare in application code and common in library code, which is where you will mostly meet them.
An options object beats four parameters
Worth stating as a design point, since TypeScript makes the alternative pleasant. Compare:
request(path, 'POST', body, true, signal);with what the app actually writes:
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'POST', body }),Positional booleans are unreadable at the call site and impossible to extend without touching every caller. An options object is self-documenting, each field can be optional independently, and adding one is a non-breaking change.
The Omit<RequestOptions, 'method' | 'body'> there is doing something neat —
it removes the two options this wrapper has already decided, so a caller cannot pass a
method to post. That is lesson
14.
Async functions
An async function always returns a promise, and the annotation says what it resolves
to — not what it returns:
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {Writing : T there is an error. The value handed back is a promise, and the type has
to say so.
Inside the function you return the resolved value and TypeScript wraps it:
async function total(id: UUID): Promise<number> {
const order = await api.get<Order>(`/api/orders/${id}`, { auth: true });
return order.total; // a number; the promise wrapping is automatic
}Promises do not nest — Promise<Promise<number>> is flattened to
Promise<number> — which is why returning another async call from an async function
works without ceremony.
Two mistakes the type system catches, and one it does not.
Caught: forgetting await. The value is a
Promise<Order> rather than an Order, so
order.total fails. Under strict this is usually a clear error rather than
a mystery — although a promise in a template string is not, since anything can be interpolated.
Caught: an async function passed where a synchronous callback was expected, if
the callback type has a non-void return.
Not caught: an unhandled rejection. There is no checked-exception concept here.
A function's type says nothing about what it can throw, and this is the largest hole in the type
system for everyday code. It is why the pizza app funnels failures through
toApiFailure and errorMessage rather than relying on each caller to
remember.
If you want the failure to be part of the type, that is the Outcome shape from
lesson 8 — return a discriminated
union instead of throwing, and the compiler will make callers handle both branches.
this in functions
If a function uses this, you can type it with a fake first parameter that is erased
at compile time:
function handleClick(this: HTMLButtonElement, event: MouseEvent) {
this.disabled = true;
}this is not a real parameter and callers do not pass it. The
noImplicitThis flag — part of strict — is what makes an untyped
this an error in the first place.
In modern code this comes up rarely, because arrow functions capture this
lexically and most callbacks are arrows. It is mostly useful when working with older DOM APIs or
libraries that call your function with a bound receiver.
Functions that never return
A function that always throws is typed never rather than void, and the
difference is not pedantry — it changes what the compiler knows about the code after the call:
function fail(message: string): never {
throw new Error(message);
}
function total(order: Order | null) {
if (!order) fail('No order');
return order.total; // order is narrowed to Order, because fail() cannot return
}Type it void and that last line is an error, because as far as the checker is
concerned fail might return and order might still be null.
The same applies to the assertion functions from lesson 10, and to any process-exiting helper.
One more habit worth forming while we are on signatures: name your parameters for the caller, not
for the body. A function type's parameter names appear in editor hints at every call site, so
onSelect: (product: Product) => void tells a reader more than
onSelect: (p: Product) => void, at a cost of six characters paid once.
And prefer a named function to an arrow assigned to a const when the function is
exported. The declaration form is hoisted, gets a real name in stack traces, and — as
lesson 13 notes — avoids the trailing-comma
awkwardness when it is generic in a .tsx file.
Next
Classes — access modifiers, the two kinds of private, and the build flag that bans one of TypeScript's most-used class features.