TypeScriptFunction Type Expressions

Function Type Expressions

Function type expressions are TypeScript's way of describing the shape of a function — its parameters and return value — without writing the function body. Think of them as a contract: any function that matches the described signature can be used wherever that type is expected.

// A function type expression describing "takes a string, returns a number"
type StringToNumber = (input: string) => number;

This becomes essential when you're passing functions as arguments, storing them in data structures, or building higher-order utilities. Let's start from the basics and work up to advanced patterns.

Inline Function Type Expressions

The simplest form writes the type directly where it's used — inline, without a separate name. The syntax mirrors an arrow function but replaces the body with a return type annotation:

(paramName: ParamType) => ReturnType

Here's what that looks like in practice:

TS
// Inline type on a variable
const double: (n: number) => number = (n) => n * 2;

// Inline type on a function parameter
function applyTwice(value: number, fn: (n: number) => number): number {
  return fn(fn(value));
}

applyTwice(3, double);       // 12
applyTwice(3, n => n + 10);  // 23

// Multiple parameters
const add: (a: number, b: number) => number = (a, b) => a + b;

// No return value
const log: (message: string) => void = (msg) => console.log(msg);
Note
Parameter names in function type expressions are required but purely for documentation — TypeScript only checks the types, not the names. (x: number) => void and (value: number) => void are identical types.
Named Function Types with Type Aliases

When the same function shape appears in multiple places, give it a name using type. This keeps your code DRY and makes intent clear at a glance.

TS
// Define once, use everywhere
type Predicate<T> = (item: T) => boolean;
type Transformer<T, U> = (input: T) => U;
type Callback<T> = (error: Error | null, result: T | null) => void;

// Reuse across multiple functions
function filter<T>(arr: T[], predicate: Predicate<T>): T[] {
  return arr.filter(predicate);
}

function map<T, U>(arr: T[], transform: Transformer<T, U>): U[] {
  return arr.map(transform);
}

// Usage
const isEven: Predicate<number> = n => n % 2 === 0;
const toUpperCase: Transformer<string, string> = s => s.toUpperCase();

filter([1, 2, 3, 4, 5], isEven);          // [2, 4]
map(['hello', 'world'], toUpperCase);      // ['HELLO', 'WORLD']
Tip
Name your function types after what they do or represent, not after their signature. Predicate is clearer than ItemToBoolean.
Function Type Expressions vs Call Signatures

TypeScript offers two ways to describe a callable type. Knowing when to use each saves you confusion later.

Function type expression — concise, used with type:

type Greeter = (name: string) => string;

Call signature inside an object type — used with interface or an object type, allows adding properties alongside the callable:

interface Greeter {
  (name: string): string;  // call signature
  language: string;        // extra property
}

Here is when each shines:

TS
// --- Function type expression: simple callbacks ---
type EventHandler = (event: MouseEvent) => void;

// --- Call signature: callable objects with properties ---
interface Logger {
  (message: string): void;    // can be called: logger("hello")
  level: 'info' | 'warn' | 'error';
  prefix: string;
}

function createLogger(level: Logger['level']): Logger {
  const logger = ((message: string) => {
    console.log(`[${logger.level}] ${logger.prefix}${message}`);
  }) as Logger;

  logger.level = level;
  logger.prefix = '';
  return logger;
}

const warn = createLogger('warn');
warn.prefix = '[APP] ';
warn('Something looks off');  // [warn] [APP] Something looks off

// --- Construct signatures: new-able types ---
interface ClockConstructor {
  new (hour: number, minute: number): Date;
}

Feature

Function Type Expression

Call Signature

Syntax location

type alias

interface or object type

Extra properties

No

Yes

Extends/implements

No

Yes (interface)

Best for

Callbacks, HOFs

Callable objects, SDKs

Typing Callbacks and Higher-Order Functions

Higher-order functions — functions that accept or return other functions — are where function type expressions prove their worth most clearly.

TS
// Typing a simple callback
type DoneCallback = (error: Error | null) => void;

function fetchData(url: string, onDone: DoneCallback): void {
  fetch(url)
    .then(res => res.json())
    .then(_data => onDone(null))
    .catch(err => onDone(err));
}

// Higher-order function: returns a function
type Multiplier = (factor: number) => (value: number) => number;

const multiplyBy: Multiplier = factor => value => value * factor;

const triple = multiplyBy(3);
const half   = multiplyBy(0.5);

triple(10);  // 30
half(10);    // 5

// Compose: takes two functions, returns their composition
type Fn<A, B> = (a: A) => B;

function compose<A, B, C>(f: Fn<B, C>, g: Fn<A, B>): Fn<A, C> {
  return (a: A) => f(g(a));
}

const parseAndDouble = compose(
  (n: number) => n * 2,
  (s: string) => parseInt(s, 10)
);

parseAndDouble('21');  // 42
Note
When TypeScript can infer a callback's parameter types from context (called contextual typing), you don't need to annotate them. [1,2,3].map(n => n * 2) — TypeScript already knows n is a number.
Generic Function Type Expressions

Generics let a function type expression work across many types while preserving the relationship between them. The type parameter goes before the parameter list.

TS
// Generic function type — the type parameter lives on the alias
type Identity<T> = (value: T) => T;
type Pair<T, U> = (first: T, second: U) => [T, U];
type Reducer<S, A> = (state: S, action: A) => S;

// Using them
const identity: Identity<number> = n => n;
const pair: Pair<string, number> = (s, n) => [s, n];

// Generic reducer
type CountState = { count: number };
type CountAction = { type: 'increment' | 'decrement'; amount: number };

const countReducer: Reducer<CountState, CountAction> = (state, action) => {
  switch (action.type) {
    case 'increment': return { count: state.count + action.amount };
    case 'decrement': return { count: state.count - action.amount };
  }
};

countReducer({ count: 0 }, { type: 'increment', amount: 5 });
// { count: 5 }

// Generic constraint: only functions that take an array
type ArrayProcessor<T, U> = (items: T[]) => U;

const joinStrings: ArrayProcessor<string, string> = items => items.join(', ');
joinStrings(['a', 'b', 'c']);  // 'a, b, c'
Tip
If the generic type parameter appears only in the return type and not the parameters, TypeScript cannot infer it — you will need to supply it explicitly when calling the function.
Conditional Types in Return Positions

Conditional types let a function's return type depend on what's passed in. The syntax follows the ternary pattern:

T extends U ? TrueType : FalseType

This is powerful but easy to overuse. Reach for it when the return type genuinely differs based on the input type — not just its value.

TS
// Unwrap a Promise to its inner type
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>;  // string
type B = Unwrap<number>;           // number

// Function using a conditional return type
function ensureArray<T>(value: T | T[]): T[] {
  return Array.isArray(value) ? value : [value];
}

// With explicit conditional return type
type WrapInArray<T> = T extends unknown[] ? T : T[];

function toArray<T>(value: T): WrapInArray<T> {
  return (Array.isArray(value) ? value : [value]) as WrapInArray<T>;
}

toArray('hello');       // ['hello']  — type: string[]
toArray([1, 2, 3]);    // [1, 2, 3]  — type: number[]

// Conditional return based on a boolean flag
function parseJSON<T extends boolean>(
  json: string,
  strict: T
): T extends true ? Record<string, unknown> : unknown {
  const parsed = JSON.parse(json);
  return parsed;
}
Warning
Conditional return types require a type assertion (as) in the implementation body — TypeScript cannot narrow the implementation's return based on generic conditions at runtime. Use them thoughtfully; the complexity cost can outweigh the benefit.
The infer Keyword — A Brief Introduction

infer works inside conditional types to extract a type from a larger type. You can think of it as pattern-matching for types: "if this type matches this shape, pull out the part I care about."

TS
// Extract the return type of any function
type ReturnTypeOf<F> = F extends (...args: any[]) => infer R ? R : never;

type A = ReturnTypeOf<() => string>;           // string
type B = ReturnTypeOf<(n: number) => boolean>; // boolean
type C = ReturnTypeOf<string>;                 // never (not a function)

// Extract parameter types as a tuple
type ParamsOf<F> = F extends (...args: infer P) => any ? P : never;

type D = ParamsOf<(a: string, b: number) => void>;  // [string, number]

// Extract only the first parameter
type FirstParam<F> = F extends (first: infer P, ...rest: any[]) => any
  ? P
  : never;

type E = FirstParam<(id: number, name: string) => void>;  // number

// Unwrap nested Promises recursively
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;

type F = DeepUnwrap<Promise<Promise<Promise<string>>>>;  // string
Note
TypeScript ships built-in utility types that use infer under the hood: ReturnType<F>, Parameters<F>, ConstructorParameters<C>, and InstanceType<C>. Use those before rolling your own.
Practical Example: Array Methods and Promise Chains

Let's see how function type expressions compose in a real-world data pipeline — the kind you'd write when processing API responses.

TS
type ApiUser = {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
};

// Typed pipeline helpers
type UserFilter     = (user: ApiUser) => boolean;
type UserMapper<U>  = (user: ApiUser) => U;

function processUsers<U>(
  users: ApiUser[],
  filter: UserFilter,
  mapper: UserMapper<U>
): U[] {
  return users.filter(filter).map(mapper);
}

// Concrete implementations
const isAdmin: UserFilter = user => user.role === 'admin';

const toDisplayName: UserMapper<string> =
  user => `${user.name} <${user.email}>`;

// Usage
declare const users: ApiUser[];
const adminDisplayNames = processUsers(users, isAdmin, toDisplayName);
// string[]

// Promise chain typing
type AsyncTransformer<T, U> = (value: T) => Promise<U>;

async function pipeline<A, B, C>(
  input: A,
  first: AsyncTransformer<A, B>,
  second: AsyncTransformer<B, C>
): Promise<C> {
  return second(await first(input));
}

const fetchUser: AsyncTransformer<number, ApiUser> = async (id) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
};

const extractEmail: AsyncTransformer<ApiUser, string> =
  async user => user.email;

pipeline(42, fetchUser, extractEmail);  // Promise<string>
Success
Composing typed transformers like this gives you end-to-end type safety across async pipelines — TypeScript will catch a mismatch between the output of fetchUser and the input expected by extractEmail at compile time.
Practical Example: Middleware Pipeline

Express-style middleware is a classic higher-order function pattern. Here's how to type it properly from scratch.

TS
// Generic context threaded through middleware
type Context<T extends Record<string, unknown> = Record<string, unknown>> = {
  data: T;
  done: boolean;
};

type Middleware<T extends Record<string, unknown>> = (
  ctx: Context<T>,
  next: () => void
) => void;

type Pipeline<T extends Record<string, unknown>> = {
  use: (middleware: Middleware<T>) => Pipeline<T>;
  run: (ctx: Context<T>) => void;
};

function createPipeline<T extends Record<string, unknown>>(): Pipeline<T> {
  const middlewares: Middleware<T>[] = [];

  return {
    use(middleware) {
      middlewares.push(middleware);
      return this;
    },
    run(ctx) {
      let index = 0;
      const next = () => {
        if (index < middlewares.length && !ctx.done) {
          middlewares[index++](ctx, next);
        }
      };
      next();
    },
  };
}

// Usage
type RequestContext = { userId?: number; authorized: boolean };

const authMiddleware: Middleware<RequestContext> = (ctx, next) => {
  ctx.data.authorized = ctx.data.userId !== undefined;
  if (!ctx.data.authorized) {
    ctx.done = true;
    console.log('Unauthorized');
    return;
  }
  next();
};

const logMiddleware: Middleware<RequestContext> = (ctx, next) => {
  console.log('Processing request for user', ctx.data.userId);
  next();
};

createPipeline<RequestContext>()
  .use(authMiddleware)
  .use(logMiddleware)
  .run({ data: { userId: 1, authorized: false }, done: false });
Type-Safe Event Emitter Pattern

A fully type-safe event emitter ensures that every event name maps to the correct handler signature — no more silent mismatches between emitted payloads and registered listeners.

TS
// Map event names to their payload types
type EventMap = {
  userLoggedIn:  { userId: number; timestamp: Date };
  userLoggedOut: { userId: number };
  pageViewed:    { path: string; duration: number };
  error:         { message: string; code: number };
};

// Derive handler types from the map
type EventHandler<E extends keyof EventMap> = (payload: EventMap[E]) => void;

class TypedEventEmitter {
  private handlers: {
    [E in keyof EventMap]?: Array<EventHandler<E>>;
  } = {};

  on<E extends keyof EventMap>(event: E, handler: EventHandler<E>): void {
    if (!this.handlers[event]) {
      this.handlers[event] = [];
    }
    (this.handlers[event] as Array<EventHandler<E>>).push(handler);
  }

  off<E extends keyof EventMap>(event: E, handler: EventHandler<E>): void {
    const list = this.handlers[event];
    if (!list) return;
    this.handlers[event] = list.filter(h => h !== handler) as typeof list;
  }

  emit<E extends keyof EventMap>(event: E, payload: EventMap[E]): void {
    (this.handlers[event] ?? []).forEach(handler =>
      (handler as EventHandler<E>)(payload)
    );
  }
}

const emitter = new TypedEventEmitter();

// TypeScript knows the payload shape for each event
emitter.on('userLoggedIn', ({ userId, timestamp }) => {
  console.log(`User ${userId} logged in at ${timestamp.toISOString()}`);
});

emitter.emit('userLoggedIn', { userId: 42, timestamp: new Date() });

// This would be a compile-time error:
// emitter.emit('userLoggedIn', { userId: '42' });
//                                        ^^^^  string is not assignable to number
Success
The EventMap is the single source of truth. Add a new event there, and TypeScript automatically enforces the correct payload shape in every on and emit call — no runtime surprises.
Common Mistakes and How to Avoid Them
  • Forgetting that void and undefined are different return types — void means "I don't care about the return value", undefined means "it must return undefined"

  • Using Function (capital F) as a type — it accepts any callable but provides no parameter or return type safety; always prefer a specific function type expression

  • Mutating parameters inside a callback typed as readonly — function type expressions respect readonly, but the violation only surfaces if you annotate the parameter correctly

  • Assuming parameter count must match exactly — TypeScript allows passing a function with fewer parameters than the type requires (structural subtyping for callbacks)

  • Writing overly broad generic constraints like T extends Function — narrow to the actual shape you need

TS
// Bad: Function type loses all safety
function run(fn: Function) {
  fn(1, 'extra', true);  // no error — anything goes
}

// Good: specific shape
function runTyped(fn: (n: number) => void) {
  fn(1);  // TypeScript checks this
}

// Fewer parameters is fine (structural subtyping)
type Handler = (event: MouseEvent, index: number) => void;

// Valid: ignoring the second parameter is allowed
const handler: Handler = (event) => console.log(event.type);

// void vs undefined
type ReturnsVoid      = () => void;
type ReturnsUndefined = () => undefined;

const a: ReturnsVoid      = () => 42;    // OK — void ignores return value
// const b: ReturnsUndefined = () => 42; // Error — must return undefined
Warning
Never type a callback as Function. It opts out of all parameter and return type checking and defeats the purpose of TypeScript entirely.
Quick Reference

Pattern

Syntax

Use case

Inline type

(x: T) => U

One-off parameter annotation

Type alias

type F = (x: T) => U

Reusable, named shape

Generic alias

type F<T> = (x: T) => T

Works across multiple types

Call signature

{ (x: T): U; prop: V }

Callable object with properties

Conditional return

T extends U ? A : B

Return type varies by input type

infer extraction

infer R inside conditional

Pull out a type from a larger one

Summary
  1. Function type expressions describe a callable's shape: (param: Type) => ReturnType

  2. Use inline expressions for one-offs; named type aliases for reuse across your codebase

  3. Prefer function type expressions for callbacks and HOFs; use call signatures when the callable also carries properties

  4. Generics make function types flexible while preserving the relationships between parameter and return types

  5. Conditional return types and infer unlock advanced patterns but add complexity — use built-in utilities like ReturnType<F> and Parameters<F> first

  6. A typed event emitter and middleware pipeline are canonical examples of these patterns working together at scale

Tip
The best mental model: a function type expression is a contract. Write the contract first, then let TypeScript verify that every implementation and every caller honours it.