TypeScript Basics #5: Function Types

5 min read

Last time we covered union types, literal types, and narrowing. This time we cover the tools for expressing function types precisely — optional/default parameters, function signatures, overloads, and a first encounter with generics.

Function signature recap #

Starting from the basic form:

Function signature
function add(a: number, b: number): number {
  return a + b;
}
  • Parameter types: a: number, b: number
  • Return type: : number

The return type is often omitted because it’s inferred well for simple functions. That said, declaring it makes intent explicit and immediately flags a function body that returns something unintended.

Return type inference
function add(a: number, b: number) {
  return a + b;       // inferred return type: number
}

function add(a: number, b: number) {
  return String(a + b);   // inferred as string — hard to notice if not intended
}

In large codebases, some teams adopt a convention of always annotating return types on function signatures. Relying on inference alone makes it easy to miss when a function body’s type drifts unintentionally.

Arrow functions and function expressions #

You can annotate types the same way you do for function declarations.

Arrow function
const add = (a: number, b: number): number => a + b;
Function expression
const subtract = function(a: number, b: number): number {
  return a - b;
};

The two differ only in JavaScript semantics; the TypeScript annotation side is essentially the same.

Naming the function type itself (recap of #3) #

The pattern of giving a function signature a type alias and reusing it comes up often.

Function type alias
type BinaryOp = (a: number, b: number) => number;

const add: BinaryOp = (a, b) => a + b;
const multiply: BinaryOp = (a, b) => a * b;

Thanks to the BinaryOp alias:

  • add and multiply definitions are short (no need to annotate each parameter)
  • the same signature is expressed consistently in many places
  • it’s especially useful for library APIs with many callbacks

Optional parameters #

Adding ? makes the argument optional.

Optional
function greet(name: string, greeting?: string): string {
  if (greeting) {
    return `${greeting}, ${name}!`;
  }
  return `안녕, ${name}!`;
}

greet('철수');           // ✓ '안녕, 철수!'
greet('영희', '환영');   // ✓ '환영, 영희!'

Optional parameters must always follow required ones.

🚫 Optional in front
function bad(greeting?: string, name: string): string {  // error: required after optional
  // ...
}

Optional parameters have type T | undefined. When using greeting inside the function, you need to account for the possibility that it’s undefined (using narrowing from #4).

Default parameters #

You can specify a default value to use when no argument is passed. Leave out the optional marker (?) in this case.

Default value
function greet(name: string, greeting: string = '안녕'): string {
  return `${greeting}, ${name}!`;
}

greet('철수');           // '안녕, 철수!'
greet('영희', '환영');   // '환영, 영희!'

Parameters with a default are automatically inferred from the default value’s type (the default fills in when undefined is passed).

Rest parameters #

Rest parameters collect a variable number of arguments of the same type.

rest
function sum(...numbers: number[]): number {
  return numbers.reduce((acc, n) => acc + n, 0);
}

sum(1, 2, 3);           // 6
sum(1, 2, 3, 4, 5);     // 15

...numbers: number[] means “zero or more numbers, gathered into an array.”

To mix multiple types, use a union or tuple:

rest with union
function logAll(...args: (string | number)[]): void {
  args.forEach(a => console.log(a));
}

logAll('a', 1, 'b', 2);

Function’s this type (rare) #

When you want to annotate the type of this inside a function, declare it as a fake first parameter.

this type
function clickHandler(this: HTMLButtonElement, event: MouseEvent): void {
  console.log(this.textContent);  // we now know this is HTMLButtonElement
}

The this parameter is not passed at the call site — it exists purely as a type annotation. It’s occasionally used when dealing with class or object methods.

Function overloads #

This lets a single function name carry different signatures. Use it when a function’s behavior changes depending on the type of its arguments.

Overload
// Signature declarations
function getValue(key: string): string;
function getValue(key: number): number;

// Implementation
function getValue(key: string | number): string | number {
  if (typeof key === 'string') {
    return `key는 문자열: ${key}`;
  }
  return key * 2;
}

const a = getValue('hello');   // type: string
const b = getValue(42);         // type: number

The two function getValue(...) declarations at the top are overload signatures; the one below is the implementation. External callers must match one of the overload signatures; the implementation signature is not visible to them.

Overloads are powerful but heavy, so it’s often cleaner to use union types or generics when possible. Reach for overloads only when the return type genuinely depends on the type of argument passed.

Generics — treating types like variables #

Look at this function. It just returns its input as-is.

Simple function
function identityNumber(value: number): number {
  return value;
}

function identityString(value: string): string {
  return value;
}

Writing essentially the same function for every type is awkward. With generics you can express it as a single function.

Generic identity
function identity<T>(value: T): T {
  return value;
}

const a = identity<number>(42);       // T = number, returns number
const b = identity<string>('hello');  // T = string, returns string

// Usually inferred without annotation
const c = identity(42);               // T inferred as number
const d = identity('hello');          // T inferred as string

<T> is a type variable — a slot that’s filled in with the actual type when the function is called. When number is passed for T, the parameter becomes number and the return type becomes number.

By convention, single letters like T (Type), U, and V are common, but more descriptive names (TItem, TKey, TValue) work just as well.

Where generics shine — collection functions #

Generics shine in collection-handling functions. Consider a function that returns the first item of an array:

first-item function
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const a = first([1, 2, 3]);              // a: number | undefined
const b = first(['x', 'y']);             // b: string | undefined
const c = first([{ id: 1 }, { id: 2 }]); // c: { id: number } | undefined

The same function infers an appropriate return type from the array passed in. If you typed the parameter as any[], the return type would also be any and type information would be lost — generics preserve it precisely.

A function that maps an array:

map-like
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  const result: U[] = [];
  for (const item of arr) {
    result.push(fn(item));
  }
  return result;
}

const lengths = map(['a', 'bc', 'def'], s => s.length);
// lengths: number[]

This uses two type variables (T, U). The input array’s element type (T) and the transform’s result type (U) can differ.

Generics + callback signatures #

Functions that take callbacks and use generics can express very expressive APIs.

Fetch simulation
async function fetchData<T>(url: string): Promise<T> {
  const res = await fetch(url);
  return await res.json() as T;
}

type User = { id: string; name: string };

const user = await fetchData<User>('/api/users/1');
// user: User

const users = await fetchData<User[]>('/api/users');
// users: User[]

The caller specifies T to declare the expected response shape. The fetch function itself is type-agnostic, but a type is stamped onto it at the call site.

(Caution: the as T above does not validate anything at runtime. It’s your responsibility to ensure the response actually matches that shape. In practice, use a validation library like Zod alongside.)

Constraints (extends) #

You can place requirements on a generic type.

Constraint — only types with length
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}

logLength('hello');       // ✓ string has length
logLength([1, 2, 3]);     // ✓ array has length
logLength({ length: 5 }); // ✓
logLength(42);            // 🚫 number has no length

T extends { length: number } constrains T to types that have a length property. Thanks to the constraint, value.length is safely accessible in the function body.

We cover constraints in more depth in #6.

Default types #

You can also give a generic parameter a default.

Generic default
function createList<T = string>(): T[] {
  return [];
}

const a = createList();            // T = string (default), returns string[]
const b = createList<number>();    // T = number, returns number[]

If the caller doesn’t specify a type, the default is used. Setting the most common case as the default is a natural and ergonomic pattern.

Putting function signatures inside objects #

When writing a function as a method on an object, you have two notations:

Method signature
type Counter = {
  increment(by: number): number;        // method form
};

type CounterAlt = {
  increment: (by: number) => number;    // arrow-function form
};

These mean nearly the same thing, with subtle differences in variance checks under the strictFunctionTypes option. Either is fine in everyday code.

Try it yourself — small utilities #

A small set of utilities combining generics and function types.

utils.ts
// 1. merge two objects into a new one
function merge<A, B>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const merged = merge({ name: '철수' }, { age: 30 });
// merged: { name: string } & { age: number }
console.log(merged.name, merged.age);

// 2. find the first item in an array that matches the predicate
function findFirst<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
  for (const item of arr) {
    if (predicate(item)) return item;
  }
  return undefined;
}

const numbers = [1, 2, 3, 4, 5];
const evenFirst = findFirst(numbers, n => n % 2 === 0);  // 2

const users = [
  { id: 'u-1', name: '철수' },
  { id: 'u-2', name: '영희' },
];
const me = findFirst(users, u => u.id === 'u-1');  // { id: 'u-1', name: '철수' }

// 3. pick only specific keys from an object (a simple Pick)
function pickKeys<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}

const fullUser = { id: 'u-1', name: '철수', email: 'cheolsu@example.com', age: 30 };
const summary = pickKeys(fullUser, ['id', 'name']);
// summary: { id: string; name: string }

keyof T and Pick<T, K> from the last pickKeys example are covered in detail in #6 and #7. For now, just notice “this is possible.”

Common pitfalls #

1. Parameter-type inference fails #

When the parameter type of an arrow function isn’t inferred:

Inference failure
[1, 2, 3].forEach(item => {
  // item: number — inferred fine
});

const fn = item => {
  // 🚫 item inferred as any (an error under `noImplicitAny`)
};

When a function is passed inline as a callback, inference can use the outer call’s signature. But if you split it out into a standalone variable, inference weakens, and you’ll need to annotate the type explicitly.

2. Trusting generic inference too much #

Inference too narrow
function pickFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const items = ['a', 'b', 'c'] as const;   // readonly ['a', 'b', 'c']
const first = pickFirst(items);
// first: 'a' | 'b' | 'c' | undefined  — narrowing may not be what you wanted

Passing an as const array to a generic function infers a very narrow type. That’s great if it’s intentional; otherwise, specify the type explicitly with pickFirst<string>(items).

3. Variance of function parameters #

Callback variance trap
type Handler = (event: MouseEvent) => void;

const h: Handler = (event: Event) => {
  // ✓ a function that takes Event can also take MouseEvent (taking a less specific type is OK)
};

const h2: Handler = (event: MouseEvent & { detail: number }) => {
  // 🚫 requiring a more specific type breaks compatibility
};

The compatibility rules for function parameters feel awkward at first. You don’t run into them often in day-to-day work, so understanding them when they come up is enough.

Wrap-up #

This post covered tools for expressing function types precisely.

  • Parameters: optional (?), default (= value), rest (...)
  • Naming function signatures with type aliases (type BinaryOp = (a, b) => number)
  • Function overloads — when the return type depends on the parameters
  • Introduction to generics — using <T> to treat types like variables
  • Constraints (T extends ...) and defaults (T = string)

Generics got just a taste here; the next post goes deep. In “TypeScript Basics #6: Generics in Depth” we cover more sophisticated constraints, multiple type parameters, generic interfaces and classes, and powerful tools like keyof. You don’t need to memorize everything at once — the expressive power scales as your familiarity grows.

X