Return Types
TypeScript can infer return types from the function body, so why bother annotating them explicitly? Because explicit annotations act as a contract — the compiler verifies that every code path returns something compatible, not just that it infers a type. This section covers every return type you will encounter, from the simple to the advanced.
Explicit Return Type Annotations
Place the return type annotation after the closing parenthesis, before the body:
// Without annotation — TypeScript infers string | number
function format(value: string | number) {
if (typeof value === 'string') return value.trim();
return value.toFixed(2);
}
// With annotation — you declare the intent, TS verifies it
function format2(value: string | number): string {
if (typeof value === 'string') return value.trim();
return value.toFixed(2); // toFixed returns string — OK
}
// TS catches mistakes immediately:
function broken(value: string | number): string {
if (typeof value === 'string') return value.trim();
return value; // TS2322: number is not assignable to string
}Why annotate when TypeScript can infer?
Documents intent — readers see the contract without reading the body
Catches bugs at the definition, not at every call site
Prevents return type from accidentally widening (e.g. string | undefined instead of string)
Required for overloaded functions and some recursive functions
void — Function Returns Nothing Useful
void is the return type for functions whose return value should not be used.
It is distinct from undefined:
// void: callers should not use the return value
function logMessage(msg: string): void {
console.log(msg);
// implicitly returns undefined, but typed as void
}
// void allows returning a value (for callback compatibility)
type Callback = () => void;
const cb: Callback = () => 42; // OK — the 42 is discarded
// undefined: must explicitly return undefined
function mustReturnUndefined(): undefined {
return undefined; // required
// return 42; // TS2322 error
}
const result = logMessage('hi'); // result is void — don't use itvoid for event handlers, callbacks, and fire-and-forget functions. Use undefined only when you truly want to enforce that nothing is returned.never — Function Never Returns
never is the return type of a function that cannot complete normally — it
either always throws an error or enters an infinite loop. TypeScript uses
never for exhaustiveness checking:
// Always throws — never returns
function invariant(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(`Invariant failed: ${message}`);
}
function fail(message: string): never {
throw new Error(message);
}
// Infinite loop — also never
function runForever(): never {
while (true) {
// server event loop, etc.
}
}
// Exhaustiveness guard
type Direction = 'north' | 'south' | 'east' | 'west';
function getVector(dir: Direction): [number, number] {
switch (dir) {
case 'north': return [0, 1];
case 'south': return [0, -1];
case 'east': return [1, 0];
case 'west': return [-1, 0];
default: return fail(`Unknown direction: ${dir}`);
// If you add 'up' to Direction, TS will still find a path
// to the default — consider assertNever for stricter exhaustion
}
}Return type | Meaning | Assignable to everything? |
|---|---|---|
never | Function never returns — throws or loops | Yes — never is a subtype of all types |
void | Return value should not be used | No — void is not a value type |
undefined | Must return undefined | No — only assignable to undefined/unknown |
undefined vs void in Practice
// void in a callback type allows any return — very permissive
const forEach: (fn: () => void) => void = (fn) => fn();
// This is intentional: Array.prototype.forEach uses () => void
// so you can pass a function that returns a value without error
['a', 'b', 'c'].forEach((s) => s.length); // OK — return value ignored
// When strict undefined checking matters:
type MaybeTransform = (value: string) => string | undefined;
function tryTransform(
value: string,
transform: MaybeTransform
): string {
const result = transform(value);
return result ?? value; // handle undefined explicitly
}Returning Union Types
A function can return one of several types. The caller must narrow the return value before using type-specific members:
type Success<T> = { ok: true; value: T };
type Failure = { ok: false; error: string };
type Result<T> = Success<T> | Failure;
function parseJSON<T>(raw: string): Result<T> {
try {
return { ok: true, value: JSON.parse(raw) as T };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
const result = parseJSON<{ name: string }>('{"name":"Alice"}');
// Caller must narrow
if (result.ok) {
console.log(result.value.name); // string
} else {
console.error(result.error); // string
}Promise Return Types for Async Functions
Annotate async functions with Promise<T> where T is the resolved value.
TypeScript automatically wraps the return in a Promise, so you never need to
annotate the actual return statement — only the function signature:
interface User {
id: string;
name: string;
email: string;
}
// Annotate with Promise<User>
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); // TypeScript trusts Promise<User> here
}
// For functions that can fail gracefully, use a Result type
async function safeFetchUser(id: string): Promise<Result<User>> {
try {
const user = await fetchUser(id);
return { ok: true, value: user };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}Promise<T> automatically for async functions. Explicit annotations help when the inferred type is wider than intended, or when the function has multiple return paths that could diverge.ReturnType Utility Type
ReturnType<T> extracts the return type of a function type as a type-level
value. This is invaluable when wrapping third-party functions:
function createStore(initial: number) {
let value = initial;
return {
get: () => value,
set: (n: number) => { value = n; },
increment: () => ++value,
};
}
// Extract what createStore returns — without duplicating the type
type Store = ReturnType<typeof createStore>;
// → { get: () => number; set: (n: number) => void; increment: () => number }
function withLogging(store: Store): Store {
return {
get: () => { console.log('get'); return store.get(); },
set: (n) => { console.log(`set(${n})`); store.set(n); },
increment: () => { console.log('increment'); return store.increment(); },
};
}// Awaited + ReturnType for async functions
async function getConfig() {
return { host: 'localhost', port: 5432 };
}
type Config = Awaited<ReturnType<typeof getConfig>>;
// → { host: string; port: number }Overloads for Narrowed Return Types
Sometimes the return type depends on the argument types. Use overloads to tell TypeScript about this relationship:
function parse(input: string): string[];
function parse(input: number): number[];
function parse(input: string | number): string[] | number[] {
if (typeof input === 'string') return input.split(',');
return [input];
}
const strings = parse('a,b,c'); // string[]
const numbers = parse(42); // number[]
// No union in the result — TypeScript picks the right overloadGenerator Function Return Types
Generator functions return a Generator<Yield, Return, Next> type:
Yield— the type of values produced byyieldReturn— the type of the final return valueNext— the type passed togenerator.next(value)
// A simple infinite counter generator
function* counter(start = 0): Generator<number, void, unknown> {
let n = start;
while (true) {
yield n++;
}
}
const gen = counter(10);
console.log(gen.next().value); // 10
console.log(gen.next().value); // 11
console.log(gen.next().value); // 12// Async generator — yields values asynchronously
async function* paginate<T>(
fetcher: (page: number) => Promise<T[]>,
maxPages = Infinity
): AsyncGenerator<T, void, unknown> {
let page = 1;
while (page <= maxPages) {
const items = await fetcher(page);
if (items.length === 0) return;
for (const item of items) yield item;
page++;
}
}
// Consume with for-await-of
for await (const user of paginate((p) => fetchPage(p), 5)) {
console.log(user);
}When to Annotate vs When to Infer
TypeScript's inference is powerful — you do not need to annotate everything. Here is a practical guide:
Situation | Recommendation |
|---|---|
Simple utility functions | Let TypeScript infer |
Public API / exported functions | Always annotate — documents the contract |
Function with multiple return paths | Annotate to catch diverging paths early |
Recursive functions | Must annotate — inference may fail |
Async functions returning complex types | Annotate with Promise<T> |
Functions with overloads | Annotation required for each overload |
Private helpers | Infer unless the body is long or complex |
Common Mistakes
Confusing void and undefined — void allows any return, undefined enforces nothing
Returning never when void is intended — never means unreachable, not "no value"
Forgetting Awaited<> when extracting the return type of an async function
Not annotating exported functions — callers see inferred types that may change
Using any as a return type shortcut — defeats the purpose of TypeScript
Quick Reference
: T — explicit return type annotation after the closing parenthesis
void — return value ignored; compatible with any return
undefined — must return undefined only
never — function always throws or loops forever
Promise<T> — async function resolving to T
ReturnType<typeof fn> — extract return type from a function
Awaited<ReturnType<typeof asyncFn>> — unwrap async return type
Generator<Y, R, N> — generator with yield, return, and next types