본문으로 건너뛰기
Advertisement

3.5 함수 타입

함수는 TypeScript에서 가장 중요한 구성 요소다. 함수 자체도 값이므로 변수에 담거나 다른 함수의 인자로 전달하거나 반환값으로 사용할 수 있다. 이 모든 경우에 타입 안전성을 확보하려면 함수 타입을 정확히 표현하는 방법을 알아야 한다.

이 장에서는 함수 타입 선언 3가지 방법, 매개변수 변형, 오버로드, 콜백 타입, this 파라미터, 공변/반공변까지 함수 타입의 전체 그림을 그린다.


함수 타입 시그니처 선언 방법 3가지

방법 1: 화살표 함수 스타일 (type alias)

(매개변수: 타입) => 반환타입 형태로 가장 널리 쓰인다.

// 기본 형태
type Add = (a: number, b: number) => number;
type Greet = (name: string) => string;
type Logger = (message: string, level?: "info" | "warn" | "error") => void;
type AsyncFetcher<T> = (url: string) => Promise<T>;

// 사용 예
const add: Add = (a, b) => a + b;
const greet: Greet = (name) => `안녕하세요, ${name}!`;
const log: Logger = (message, level = "info") => {
console.log(`[${level.toUpperCase()}] ${message}`);
};

방법 2: 호출 시그니처 (Call Signature) — interface/type의 객체 표기

함수가 프로퍼티도 가질 때 사용한다. 함수이면서 동시에 객체인 타입을 표현한다.

// 호출 시그니처로 함수 + 프로퍼티 동시 정의
type ClickHandler = {
(event: MouseEvent): void; // 호출 시그니처
description: string; // 프로퍼티
once: boolean; // 프로퍼티
};

// interface 버전
interface ValidatorFn {
(value: unknown): boolean;
errorMessage: string;
ruleName: string;
}

const isPositive: ValidatorFn = Object.assign(
(value: unknown): boolean => typeof value === "number" && value > 0,
{ errorMessage: "양수여야 합니다", ruleName: "positive" }
);

console.log(isPositive(5)); // true
console.log(isPositive.errorMessage); // "양수여야 합니다"

방법 3: 메서드 시그니처 — 객체/인터페이스 안에서의 메서드

interface Calculator {
// 메서드 시그니처 (단축 형태)
add(a: number, b: number): number;
subtract(a: number, b: number): number;

// 프로퍼티로서의 함수 타입 (화살표 형태)
multiply: (a: number, b: number) => number;
divide: (a: number, b: number) => number;
}

// 메서드 시그니처 vs 프로퍼티 함수의 차이:
// 메서드 시그니처는 this를 포함한 추론이 더 유연하고,
// strictFunctionTypes 옵션 하에서 공변/반공변 규칙 적용이 다르다 (후반부에서 설명)

매개변수 타입

optional 매개변수

?를 붙이면 매개변수를 생략할 수 있다. optional 매개변수 뒤에는 필수 매개변수가 올 수 없다.

function createUser(
name: string,
email: string,
role?: "user" | "admin", // optional
bio?: string // optional, 뒤에 필수 매개변수 불가
): User {
return {
id: crypto.randomUUID(),
username: name,
email,
displayName: name,
isActive: true,
...(bio && { bio }),
} as User;
}

createUser("Alice", "alice@example.com"); // 정상
createUser("Bob", "bob@example.com", "admin"); // 정상
createUser("Charlie", "charlie@example.com", "user", "개발자"); // 정상

기본값(Default Parameter) 타입

기본값이 있는 매개변수는 optional로 간주되며 타입이 추론된다.

function paginate(
items: unknown[],
page: number = 1, // 기본값 1, 타입 추론: number
pageSize: number = 20, // 기본값 20
sortOrder: "asc" | "desc" = "asc"
): unknown[] {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}

paginate([1, 2, 3, 4, 5]); // page=1, pageSize=20
paginate([1, 2, 3], 2, 10); // page=2, pageSize=10
paginate([1, 2, 3], 1, 10, "desc"); // 명시적 sortOrder

rest 파라미터 타입

...매개변수명: T[] 형태로 나머지 인자를 배열로 받는다.

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

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

// 제네릭 rest 파라미터
function first<T>(...items: [T, ...T[]]): T {
return items[0];
}

// 튜플 타입과 결합
function log(level: "info" | "warn" | "error", ...messages: string[]): void {
console.log(`[${level.toUpperCase()}]`, ...messages);
}

log("info", "서버 시작", "포트: 3000");

함수 오버로드 (Overload)

함수 오버로드는 같은 이름의 함수가 다른 타입의 인자를 받을 때 각 케이스에 대한 타입 시그니처를 개별적으로 선언하는 기능이다.

오버로드 선언 방법

여러 개의 오버로드 시그니처(선언부) + 1개의 구현 시그니처(구현부) 구조다.

// 오버로드 시그니처 (선언부) - 타입 검사에 사용됨
function parse(input: string): number;
function parse(input: number): string;
function parse(input: boolean): string;

// 구현 시그니처 (구현부) - 직접 호출 불가, 모든 케이스를 처리해야 함
function parse(input: string | number | boolean): number | string {
if (typeof input === "string") return parseInt(input, 10);
if (typeof input === "number") return input.toString();
return input ? "true" : "false";
}

// 호출 시 정확한 반환 타입 추론
const num = parse("42"); // 타입: number
const str = parse(42); // 타입: string
const boolStr = parse(true); // 타입: string

실전 오버로드: 유연한 API 설계

// querySelector 유사 함수 — 태그명으로 정확한 요소 타입 반환
function querySelector(selector: "button"): HTMLButtonElement | null;
function querySelector(selector: "input"): HTMLInputElement | null;
function querySelector(selector: "select"): HTMLSelectElement | null;
function querySelector(selector: "textarea"): HTMLTextAreaElement | null;
function querySelector(selector: string): HTMLElement | null;
function querySelector(selector: string): HTMLElement | null {
return document.querySelector(selector);
}

const btn = querySelector("button"); // 타입: HTMLButtonElement | null
const inp = querySelector("input"); // 타입: HTMLInputElement | null
const div = querySelector(".header"); // 타입: HTMLElement | null

오버로드 vs 유니온 타입

오버로드는 입력-출력 타입 쌍이 명확히 연결되어야 할 때 사용한다.

// 유니온으로는 "string 입력 → number 출력" 보장이 안 됨
function badParse(input: string | number): string | number {
if (typeof input === "string") return parseInt(input, 10);
return input.toString();
}
const result = badParse("42"); // 타입: string | number (불정확)

// 오버로드로 정확한 타입 쌍 보장
function goodParse(input: string): number;
function goodParse(input: number): string;
function goodParse(input: string | number): string | number {
if (typeof input === "string") return parseInt(input, 10);
return input.toString();
}
const result2 = goodParse("42"); // 타입: number (정확)

콜백 타입

함수를 다른 함수의 매개변수로 전달할 때 콜백의 타입을 정의한다.

기본 콜백 타입

type Callback<T> = (error: Error | null, result: T | null) => void;
type EventCallback<T> = (event: T) => void;
type Predicate<T> = (value: T) => boolean;

function loadData<T>(url: string, callback: Callback<T>): void {
fetch(url)
.then((r) => r.json() as T)
.then((data) => callback(null, data))
.catch((err) => callback(err, null));
}

loadData<User[]>("/api/users", (err, users) => {
if (err) {
console.error(err.message);
return;
}
console.log(users?.length);
});

void 반환 타입의 특수성

콜백 반환 타입이 void이면 실제로 값을 반환해도 TypeScript가 허용한다. 이는 배열 메서드의 콜백을 기존 함수로 재사용할 때 편리하다.

type VoidCallback = () => void;

// void 반환 타입이지만 값을 반환하는 함수를 할당 가능
const handler: VoidCallback = () => "hello"; // 정상! (반환값 무시됨)

// 배열 forEach는 콜백 반환 타입이 void
[1, 2, 3].forEach((n) => n * 2); // 반환값 있어도 정상

// push는 number를 반환하지만 forEach 콜백으로 사용 가능
const result: number[] = [];
[1, 2, 3].forEach(result.push.bind(result)); // 정상

단, 함수가 반환 타입을 명시적으로 void로 선언한 경우 내부에서 반환값이 무시된다는 의미다. undefined를 반환하는 것과는 다르다.

function runCallback(cb: () => void): void {
const result = cb();
// result의 타입은 void이므로 사용 불가
}

제네릭 고차 함수 타입

// map과 유사한 고차 함수
type MapFn = <T, U>(arr: T[], fn: (item: T, index: number) => U) => U[];

const myMap: MapFn = (arr, fn) => arr.map(fn);

const doubled = myMap([1, 2, 3], (n) => n * 2); // number[]
const strings = myMap([1, 2, 3], (n) => String(n)); // string[]

// 커링(Currying) 타입
type Curry2<A, B, C> = (a: A) => (b: B) => C;

const add: Curry2<number, number, number> = (a) => (b) => a + b;
const add5 = add(5);
console.log(add5(3)); // 8

this 타입

TypeScript는 함수의 첫 번째 매개변수 자리에 this: T를 사용해 this의 타입을 명시할 수 있다. 이는 실제 매개변수가 아니라 컴파일 타임 타입 정보다.

명시적 this 파라미터

interface User {
id: string;
name: string;
email: string;
}

interface UserWithMethods extends User {
greet(this: UserWithMethods): string;
updateEmail(this: UserWithMethods, newEmail: string): void;
}

const user: UserWithMethods = {
id: "1",
name: "Alice",
email: "alice@example.com",

greet(this: UserWithMethods): string {
return `안녕하세요, ${this.name}님!`;
},

updateEmail(this: UserWithMethods, newEmail: string): void {
this.email = newEmail;
},
};

user.greet(); // 정상
// const greet = user.greet;
// greet(); // 오류: this 컨텍스트를 잃음

noImplicitThis 옵션

tsconfig.json에서 noImplicitThis: true를 설정하면 this가 암시적으로 any가 되는 경우를 오류로 처리한다.

// noImplicitThis 없이: this가 any
function badMethod() {
return this.name; // this: any → 타입 검사 없음
}

// noImplicitThis 활성화: this 타입 명시 필요
function goodMethod(this: { name: string }) {
return this.name; // 타입 안전
}

// 클래스 메서드는 자동으로 this 타입이 추론됨
class Counter {
count = 0;
increment() {
this.count++; // this: Counter (자동 추론)
}
// 화살표 함수는 this를 렉시컬 캡처
decrement = () => {
this.count--; // this: Counter (화살표로 캡처)
};
}

이벤트 핸들러에서의 this

interface EventEmitter {
on<K extends string>(
event: K,
handler: (this: EventEmitter, ...args: unknown[]) => void
): this;
emit(event: string, ...args: unknown[]): boolean;
}

실전 예제: 이벤트 핸들러 시스템, 고차 함수 타입, 오버로드된 파싱 함수

타입 안전 이벤트 에미터 시스템

// 이벤트 맵 정의
interface AppEventMap {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:cancelled": { orderId: string; reason: string };
"error": { message: string; code: number };
}

type EventName = keyof AppEventMap;
type EventPayload<E extends EventName> = AppEventMap[E];
type EventHandler<E extends EventName> = (payload: EventPayload<E>) => void;

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

on<E extends EventName>(event: E, handler: EventHandler<E>): this {
if (!this.handlers[event]) {
(this.handlers as Record<string, unknown[]>)[event] = [];
}
(this.handlers[event] as EventHandler<E>[]).push(handler);
return this;
}

off<E extends EventName>(event: E, handler: EventHandler<E>): this {
const list = this.handlers[event] as EventHandler<E>[] | undefined;
if (list) {
const index = list.indexOf(handler);
if (index >= 0) list.splice(index, 1);
}
return this;
}

emit<E extends EventName>(event: E, payload: EventPayload<E>): void {
const list = this.handlers[event] as EventHandler<E>[] | undefined;
list?.forEach((h) => h(payload));
}
}

// 사용: 완전한 타입 추론
const emitter = new TypedEventEmitter();

emitter.on("user:login", ({ userId, timestamp }) => {
console.log(`${userId} 로그인: ${timestamp.toISOString()}`);
});

emitter.on("error", ({ message, code }) => {
console.error(`[${code}] ${message}`);
});

emitter.emit("user:login", { userId: "user-1", timestamp: new Date() });
// emitter.emit("user:login", { userId: "user-1" }); // 오류: timestamp 누락

고차 함수 타입 활용

// compose: 오른쪽에서 왼쪽으로 함수 합성
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) => f(g(a));
}

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

const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;
const toString = (n: number) => `결과: ${n}`;

const transform = compose3(toString, double, addOne);
console.log(transform(5)); // "결과: 12"

// memoize: 결과를 캐시하는 고차 함수
function memoize<Args extends unknown[], R>(
fn: (...args: Args) => R
): (...args: Args) => R {
const cache = new Map<string, R>();
return (...args: Args): R => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args);
cache.set(key, result);
return result;
};
}

const expensiveCalc = memoize((n: number) => {
console.log(`계산 중... ${n}`);
return n * n;
});

console.log(expensiveCalc(5)); // "계산 중... 5", 25
console.log(expensiveCalc(5)); // 25 (캐시 사용)

오버로드된 파싱 함수

// 다양한 입력을 받아 일관된 타입으로 파싱
interface ParsedDate {
year: number;
month: number;
day: number;
}

// 오버로드 시그니처
function parseDate(input: string): ParsedDate;
function parseDate(input: number): ParsedDate; // Unix timestamp
function parseDate(input: Date): ParsedDate;
function parseDate(input: [number, number, number]): ParsedDate; // [year, month, day]

// 구현
function parseDate(
input: string | number | Date | [number, number, number]
): ParsedDate {
if (Array.isArray(input)) {
const [year, month, day] = input;
return { year, month, day };
}
if (input instanceof Date) {
return {
year: input.getFullYear(),
month: input.getMonth() + 1,
day: input.getDate(),
};
}
if (typeof input === "number") {
return parseDate(new Date(input));
}
// string: "YYYY-MM-DD"
const [year, month, day] = input.split("-").map(Number);
return { year, month, day };
}

const d1 = parseDate("2025-03-21"); // ParsedDate
const d2 = parseDate(1742515200000); // ParsedDate (Unix ts)
const d3 = parseDate(new Date()); // ParsedDate
const d4 = parseDate([2025, 3, 21]); // ParsedDate

고수 팁: 함수 타입 호환성 — 공변과 반공변

공변(Covariance)과 반공변(Contravariance)

TypeScript의 strictFunctionTypes 옵션(기본 활성화)은 함수 매개변수에 반공변을, 반환값에 공변을 적용한다.

공변(반환 타입): 더 좁은 타입(서브타입)을 더 넓은 타입(슈퍼타입) 자리에 사용 가능

class Animal { name: string = "" }
class Dog extends Animal { bark() { console.log("멍멍") } }

type MakeAnimal = () => Animal;
type MakeDog = () => Dog;

// 반환 타입: Dog는 Animal의 서브타입 → MakeDog는 MakeAnimal에 할당 가능 (공변)
const makeDog: MakeDog = () => new Dog();
const makeAnimal: MakeAnimal = makeDog; // 정상: Dog를 반환하면 Animal도 반환한 셈

반공변(매개변수 타입): 더 넓은 타입(슈퍼타입)을 더 좁은 타입(서브타입) 자리에 사용 가능

type HandleAnimal = (animal: Animal) => void;
type HandleDog = (dog: Dog) => void;

// 매개변수: Animal을 받는 함수는 Dog 자리에도 사용 가능 (반공변)
const handleAnimal: HandleAnimal = (a) => console.log(a.name);
const handleDog: HandleDog = handleAnimal; // 정상: Animal 핸들러는 Dog도 처리 가능

// 반대는 불가: Dog를 받는 함수를 Animal 자리에 쓰면 Animal에 bark()가 없을 수 있음
// const bad: HandleAnimal = (d: Dog) => d.bark(); // 오류

strictFunctionTypes 와 메서드 시그니처 차이

interface WithArrow {
handle: (animal: Animal) => void; // 프로퍼티(화살표) — strictFunctionTypes 적용
}

interface WithMethod {
handle(animal: Animal): void; // 메서드 시그니처 — bivariant (공변+반공변)
}

// 메서드 시그니처는 하위 호환을 위해 이분산(bivariant) 유지
// 실용적으로는 프로퍼티 화살표 스타일이 더 안전

매개변수 개수와 타입 호환성

// TypeScript는 매개변수가 더 적은 함수를 허용 (JavaScript 관행)
type BinaryFn = (a: number, b: number) => number;

const unary: (a: number) => number = (a) => a;
const binary: BinaryFn = unary; // 정상: 매개변수가 더 적어도 호환

// 배열 forEach의 콜백이 index 없이도 동작하는 이유
[1, 2, 3].forEach((n) => console.log(n)); // (value, index, array) 중 value만 받아도 정상

정리 표

기능문법설명
화살표 함수 타입type F = (a: T) => U가장 일반적인 함수 타입 선언
호출 시그니처{ (a: T): U; prop: V }함수 + 프로퍼티 동시 표현
메서드 시그니처{ method(a: T): U }인터페이스 내 메서드
optional 매개변수(a: T, b?: U) => Vb는 생략 가능, U | undefined
기본값 매개변수(a: T, b: U = val) => Voptional로 간주, 타입 추론
rest 파라미터(...args: T[]) => V나머지 인자 배열로 수집
오버로드선언부 N개 + 구현부 1개입력-출력 타입 쌍 정확히 선언
콜백 void(cb: () => void)반환값 무시, 반환해도 허용
this 파라미터(this: T, a: U) => Vthis 타입 명시 (실제 인자 아님)
공변 (반환)서브타입 반환 함수 할당 가능Dog => __ ⊂ Animal => __
반공변 (인자)슈퍼타입 인자 함수 할당 가능__ => Animal ⊂ __ => Dog

다음 장에서는...

4장 제네릭에서는 TypeScript의 가장 강력한 추상화 도구인 제네릭을 다룬다. <T> 타입 파라미터를 사용해 재사용 가능한 함수·클래스·인터페이스를 작성하고, 타입 제약(extends)·기본값·조건부 타입·infer 키워드까지 제네릭 타입 시스템의 깊이를 탐구한다.

Advertisement