Functions, Parameters, and Return Types
Declaring functions, optional and default parameters, what void really means, and functions as values you can pass to other functions.
Declaring functions, optional and default parameters, what void really means, and functions as values you can pass to other functions.
Your program is ninety lines. Somewhere around line forty you worked out how to turn a filename into a tidy caption, and around line seventy you needed it again, so you copied those six lines and adjusted them. Then you found a bug in the first copy.
That is the moment functions exist for. By the end of this lesson you will name a piece of work, describe what goes in and comes out, and know why function parameters are the one place annotations are not optional.
function makeCaption(filename: string): string {
const words = filename.replaceAll("_", " ").replace(".jpg", "");
return words;
}
const caption = makeCaption("sunrise_over_lisbon.jpg");filename: string describes what goes in. : string after the
parentheses describes what comes out. The body runs when the
function is called.
Now those six lines exist once. Fixing the bug fixes every use, and the ninety-line program reads as a description of what it does rather than a transcript of how.
There is no value to infer from.
A parameter gets its value later, from whoever calls. Nothing is present at the moment the checker reads the line.
Omit it under strict and you get an error; without strict
you get any, which means "stop checking".
The body says what it is.
TypeScript reads your return statements and works it out.
Writing it anyway on anything public turns a mistake in the body into an error at the function, instead of at every caller.
Everywhere else the checker infers types from values. A parameter has no value to look at — it gets one later, from whoever calls.
function makeCaption(filename) {
return filename.toUpperCase();
}
// Parameter 'filename' implicitly has an 'any' type.That error appears because strict is on. Without it, the
parameter would be any, meaning "stop checking" — so
makeCaption(42) would compile and crash.
Return types are different: TypeScript infers them from what you return.
function makeCaption(filename: string) {
return filename.replaceAll("_", " "); // inferred as string
}Both forms are fine. Writing the return type is worth it on anything public, because it makes the function's contract explicit and catches a body that accidentally returns the wrong thing — without it, a mistake in the body silently changes what callers receive.
Two forms you will see constantly:
function makeCaption(filename: string): string { ... }
const makeCaption = (filename: string): string => { ... };The second is an arrow function. For a body that is a single
expression, the braces and return can go:
const double = (n: number): number => n * 2;
sizes.map((size) => size * 2);That last line is why arrows are everywhere: passing a small
function to map or filter reads much better as an arrow.
The main practical difference is that function declarations
can be called before they appear in the file, and arrow
functions assigned to const cannot. Beyond that, use arrows
for short callbacks and either form for named functions —
consistency within a project matters more than the choice.
function makeCaption(filename: string, separator = "_"): string {
return filename.replaceAll(separator, " ");
}
makeCaption("sunrise_over_lisbon.jpg");
makeCaption("sunrise-over-lisbon.jpg", "-");A default value makes the parameter optional and gives the
checker its type — separator is string without being
annotated.
Without a default, mark it with ?:
function makeCaption(filename: string, prefix?: string): string {
return prefix == null ? filename : `${prefix}: ${filename}`;
}prefix is string | undefined, so the checker requires the
absent case to be handled. Optional parameters must come after
required ones.
For a function with several options, an object is better than a long parameter list:
type CaptionOptions = {
separator?: string;
uppercase?: boolean;
};
function makeCaption(filename: string, options: CaptionOptions = {}) {
const { separator = "_", uppercase = false } = options;
...
}
makeCaption("a_b.jpg", { uppercase: true });The call site now says which option is which, and adding a new one breaks nobody.
Bad — a parameter type narrower than the body needs.
function totalSize(photos: Photo[]): number {
return photos.reduce((sum, p) => sum + p.size, 0);
}Good — asking for exactly what is used.
function totalSize(photos: readonly { size: number }[]): number {
return photos.reduce((sum, p) => sum + p.size, 0);
}Two things change between those, and both are about what the function is promising.
Demands more than it reads.
It uses one property and requires whole Photo objects, so a
caller holding the right data has to construct fake photos to
satisfy it.
It also accepts a mutable array, so nothing stops the body sorting the caller's list in place.
Asks for exactly what it uses.
Anything with a size fits, from anywhere in the codebase.
readonly states that the function only reads, which is a
promise the checker enforces on the body.
Ask for the least you need. The wider the parameter type, the more callers can use it, and the fewer promises you have made.
A function can be passed to another function, which is what
map and filter have been doing:
type Transform = (photo: Photo) => string;
function applyAll(photos: Photo[], transform: Transform): string[] {
return photos.map(transform);
}
applyAll(photos, (photo) => photo.name.toUpperCase());Read (photo: Photo) => string as "takes a Photo, returns a
string". That arrow is a type, not an arrow function — the
same symbol in a different position.
Notice the callback needed no annotation. TypeScript knows
transform must be a Transform, so it infers photo from the
context. That is contextual typing, and it is why callbacks
throughout this course have been bare.
function logCaption(photo: Photo): void {
console.log(makeCaption(photo.name));
}void means the function is called for what it does, not for
what it produces. It is worth writing: without it, a body that
accidentally returns something changes the function's contract
silently.
A related type is never, for a function that does not return
at all:
function fail(message: string): never {
throw new Error(message);
}never tells the checker that execution stops here, which lets
it work out that code after a fail() call is unreachable and
that a branch calling it cannot fall through.
The most useful design rule available: a function should do one thing, and its name should say what.
function processAndSaveAndNotify(photo: Photo): Photo { ... }function resize(photo: Photo): Photo { ... }
function save(photo: Photo): void { ... }
function notifyOwner(photo: Photo): void { ... }
function processUpload(photo: Photo): void {
const resized = resize(photo);
save(resized);
notifyOwner(photo);
}The first cannot be reused — there is no way to resize without also sending an email — so the next person who needs resizing copies the lines out, and now there are two. It also cannot be tested without sending real email.
If naming a function honestly requires "and", it is two functions.
DECLARING
function f(a: string): string { ... }
const f = (a: string): string => { ... };
const double = (n: number) => n * 2; one expression, no braces
function declarations can be called before they appear
arrow functions assigned to const cannot
ANNOTATIONS
parameters REQUIRED - there is no value to infer from
(without them: implicit any, and no checking)
return type inferred, but worth writing on anything public
PARAMETERS
separator = "_" a default; also gives the type
prefix?: string optional -> string | undefined
optional ones come last
many options -> one options object, destructured with defaults
ask for the LEAST you need:
readonly { size: number }[] not Photo[]
wider parameter type = more callers, fewer promises
FUNCTIONS AS VALUES
type Transform = (photo: Photo) => string;
the arrow in a TYPE position means "takes ... returns ..."
callbacks need no annotation - contextual typing infers them
NO RETURN VALUE
: void called for what it DOES
: never does not return at all (always throws)
DESIGN
one function, one job
if the honest name needs "and", it is two functions
return early instead of nestingYou can now name work, describe what crosses its boundary, and pass functions to other functions. The habit worth forming is asking for the least you need in a parameter — it costs nothing and makes a function usable in places you did not anticipate.
Next is Union Types and Narrowing, the central idea of this
language. A value that could be one of several types, and the
way the checker follows your checks to work out which one it is
on each branch — you have used it for string | null, and it is
far more general than that.
Before you move on, take the longest program you have written and pull one repeated chunk into a function. Annotate its parameters, let the return type be inferred, and hover over the function name to see what the checker worked out. Then try passing the wrong type in and read the error.