지난 시간에는 union/literal/narrowing을 다뤘습니다. 이번에는 함수의 타입을 정밀하게 표현하는 도구들을 정리합니다 — 옵셔널/디폴트 인자, 함수 시그니처, 오버로드, 그리고 제네릭과의 첫 만남까지.
함수 시그니처 복습
기본 형태부터 다시 정리:
function add(a: number, b: number): number {
return a + b;
}- 매개변수 타입:
a: number,b: number - 반환 타입:
: number
반환 타입은 추론이 잘 되니 자주 생략합니다 (단순 함수라면). 다만 명시하면 의도가 명확해지고, 함수 본문이 의도와 다른 걸 반환하면 즉시 잡혀요.
function add(a: number, b: number) {
return a + b; // 반환 타입 number로 자동 추론
}
function add(a: number, b: number) {
return String(a + b); // 반환 타입 string으로 추론 — 의도 다르면 알아채기 어려움
}큰 코드베이스에서는 함수 시그니처에 반환 타입을 명시하는 컨벤션도 있습니다. 추론에만 의존하면 함수 본문에서 타입이 살짝 달라져도 알아차리기 어렵거든요.
화살표 함수와 함수 표현식
함수 선언과 동일하게 타입을 명시할 수 있습니다.
const add = (a: number, b: number): number => a + b;const subtract = function(a: number, b: number): number {
return a - b;
};이 둘은 자바스크립트 측 차이만 있고 타입스크립트 측은 거의 같아요.
함수 타입 자체에 별명 (#3 복습)
함수 시그니처를 type alias로 별명 붙여 재사용하는 패턴은 자주 등장합니다.
type BinaryOp = (a: number, b: number) => number;
const add: BinaryOp = (a, b) => a + b;
const multiply: BinaryOp = (a, b) => a * b;BinaryOp 별명을 붙인 덕에:
add,multiply정의가 짧아짐 (개별 매개변수 타입 안 적어도 됨)- 같은 시그니처를 여러 곳에서 일관되게 표현
- 콜백 매개변수가 많은 라이브러리 API에 매우 유용
옵셔널 매개변수
?를 붙이면 안 넘겨도 됩니다.
function greet(name: string, greeting?: string): string {
if (greeting) {
return `${greeting}, ${name}!`;
}
return `안녕, ${name}!`;
}
greet('철수'); // ✓ '안녕, 철수!'
greet('영희', '환영'); // ✓ '환영, 영희!'옵셔널 매개변수는 항상 필수 매개변수 뒤에 와야 합니다.
function bad(greeting?: string, name: string): string { // 에러: 필수가 옵셔널 뒤에
// ...
}옵셔널 매개변수는 T | undefined 타입으로 처리됩니다. 함수 안에서 greeting을 사용할 때 undefined일 가능성을 의식해야 해요 (#4의 narrowing).
디폴트 매개변수
값을 안 넘기면 사용할 기본값을 지정할 수 있습니다. 이 경우 옵셔널 표시(?)는 빼야 합니다.
function greet(name: string, greeting: string = '안녕'): string {
return `${greeting}, ${name}!`;
}
greet('철수'); // '안녕, 철수!'
greet('영희', '환영'); // '환영, 영희!'디폴트가 있는 매개변수는 자동으로 string 타입으로 추론됩니다 (undefined가 들어오면 디폴트가 채우니까요).
Rest 매개변수
자바스크립트의 ...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[]는 "0개 이상의 number를 모아 배열로"라는 의미예요.
여러 타입을 섞고 싶다면 union이나 튜플 활용:
function logAll(...args: (string | number)[]): void {
args.forEach(a => console.log(a));
}
logAll('a', 1, 'b', 2);함수의 this 타입 (드물게 사용)
함수 안에서 this의 타입을 명시하고 싶을 때, 첫 번째 매개변수처럼 적습니다.
function clickHandler(this: HTMLButtonElement, event: MouseEvent): void {
console.log(this.textContent); // this가 HTMLButtonElement임을 알게 됨
}this 매개변수는 호출 시 인자로 전달되지 않습니다 — 타입 정보 표시 용도일 뿐이에요. 클래스나 객체 메소드를 다룰 때 가끔 쓰입니다.
함수 오버로드
같은 이름의 함수가 다른 시그니처들을 가질 수 있게 합니다. 매개변수 종류에 따라 동작이 달라지는 함수를 표현할 때 써요.
// 시그니처 선언들
function getValue(key: string): string;
function getValue(key: number): number;
// 실제 구현
function getValue(key: string | number): string | number {
if (typeof key === 'string') {
return `key는 문자열: ${key}`;
}
return key * 2;
}
const a = getValue('hello'); // 타입: string
const b = getValue(42); // 타입: number위쪽 두 개의 function getValue(...) 선언이 오버로드 시그니처이고, 아래 한 개가 구현입니다. 외부에서 호출할 때는 오버로드 시그니처들 중 하나에 매칭되어야 하고, 구현 시그니처는 외부에 노출되지 않아요.
오버로드는 강력하지만 약간 무거운 도구라, 가능하면 union 타입이나 제네릭으로 표현하는 게 더 깔끔할 때가 많습니다. 진짜 매개변수 종류에 따라 반환 타입이 다른 경우에만 오버로드를 쓰세요.
제네릭 — 타입을 변수처럼 다루기
다음 함수를 보세요. 입력을 그대로 반환하는 단순한 함수입니다.
function identityNumber(value: number): number {
return value;
}
function identityString(value: string): string {
return value;
}같은 일을 하는데 타입마다 따로 작성해야 하는 게 어색하죠. 제네릭을 쓰면 한 함수로 표현 가능합니다.
function identity<T>(value: T): T {
return value;
}
const a = identity<number>(42); // T = number, 반환 number
const b = identity<string>('hello'); // T = string, 반환 string
// 보통 명시 안 해도 추론됨
const c = identity(42); // T가 number로 추론됨
const d = identity('hello'); // T가 string으로 추론됨<T>가 타입 변수입니다. 함수가 호출될 때 실제 타입으로 채워지는 자리예요. T 자리에 number가 들어오면 매개변수도 number, 반환도 number가 됩니다.
이름은 관례상 T (Type), U, V 같은 한 글자가 흔하지만, 의미가 분명한 이름(TItem, TKey, TValue)도 좋습니다.
제네릭의 진가 — 컬렉션 함수
제네릭은 컬렉션 처리 함수에서 빛납니다. 배열의 첫 항목을 반환하는 함수:
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같은 함수가 들어오는 배열에 따라 적절한 반환 타입을 추론해줍니다. any[]로 받았다면 반환 타입도 any가 되어 타입 정보가 사라졌을 텐데, 제네릭 덕에 정밀하게 보존돼요.
배열을 매핑하는 함수:
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[]타입 변수가 두 개(T, U)인 경우입니다. 입력 배열의 타입(T)과 변환 결과의 타입(U)이 다를 수 있으니까요.
제네릭 + 콜백 시그니처
콜백을 받는 함수를 제네릭으로 작성하면 매우 표현력 있는 API를 만들 수 있습니다.
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[]호출자가 T를 명시해서 응답의 모양을 선언적으로 표현. fetch 함수 자체는 타입에 무관하게 동작하지만 호출 시점에 타입을 입혀주는 거예요.
(주의: 위 코드의 as T는 런타임 검증을 안 합니다. 응답이 정말 그 모양인지는 우리가 책임져야 해요. 실전에서는 Zod 같은 검증 라이브러리를 함께 씁니다.)
제약 (extends)
제네릭 타입에 조건을 걸 수 있습니다.
function logLength<T extends { length: number }>(value: T): void {
console.log(value.length);
}
logLength('hello'); // ✓ 문자열은 length 있음
logLength([1, 2, 3]); // ✓ 배열도 length 있음
logLength({ length: 5 }); // ✓
logLength(42); // 🚫 number는 length 없음T extends { length: number }는 "T는 length 프로퍼티를 가진 어떤 타입"이라는 제약. 제약 덕에 함수 본문에서 value.length를 안전하게 호출할 수 있어요.
제약은 #6에서 더 깊이 다룹니다.
디폴트 타입
제네릭 매개변수에 기본값을 줄 수도 있습니다.
function createList<T = string>(): T[] {
return [];
}
const a = createList(); // T = string (디폴트), 반환 string[]
const b = createList<number>(); // T = number, 반환 number[]호출자가 타입을 안 명시하면 디폴트가 사용됩니다. 가장 흔한 사용처를 디폴트로 두는 패턴이 자연스러워요.
함수 시그니처를 객체 안에 넣기
객체에 메소드처럼 함수를 둘 때, 두 가지 표기:
type Counter = {
increment(by: number): number; // 메소드 형태
};
type CounterAlt = {
increment: (by: number) => number; // 화살표 함수 형태
};거의 같은 의미인데 미묘한 차이가 있습니다 (strictFunctionTypes 옵션 하의 변성 검사). 일상적으론 둘 중 어느 쪽이든 상관없어요.
직접 해보기 — 작은 유틸리티
제네릭과 함수 타입을 종합한 작은 유틸 모음.
// 1. 두 객체를 합쳐 새 객체 만들기
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. 배열에서 조건에 맞는 첫 항목 찾기
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)
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 }마지막 pickKeys에서 등장한 keyof T와 Pick<T, K>는 #6과 #7에서 더 자세히 다룹니다. 지금은 "이런 게 가능하구나" 정도만 봐두세요.
흔한 함정
1. 매개변수 타입 추론 실패
화살표 함수의 매개변수 타입이 추론 안 될 때:
[1, 2, 3].forEach(item => {
// item: number — 잘 추론됨
});
const fn = item => {
// 🚫 item이 any로 추론됨 (`noImplicitAny` 옵션 하면 에러)
};함수가 콜백으로 즉시 전달되면 외부 시그니처에서 추론이 가능하지만, 분리해서 변수로 두면 추론이 약해집니다. 그럴 때는 명시적으로 타입을 적어야 해요.
2. 제네릭 자동 추론을 너무 신뢰
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 — 좁아진 게 의도와 다를 수 있음as const로 만든 배열을 제네릭 함수에 넘기면 타입이 매우 좁게 추론됩니다. 의도라면 좋고, 아니라면 pickFirst<string>(items)로 명시.
3. 함수 매개변수의 변성
type Handler = (event: MouseEvent) => void;
const h: Handler = (event: Event) => {
// ✓ Event를 받는 함수는 MouseEvent도 받을 수 있음 (덜 구체적인 타입을 받는 건 OK)
};
const h2: Handler = (event: MouseEvent & { detail: number }) => {
// 🚫 더 구체적인 타입을 요구하면 호환 안 됨
};함수 매개변수의 호환성 규칙이 처음엔 어색합니다. 일상적으로 자주 마주치진 않으니 만났을 때 이해하면 충분해요.
마무리
이번 글에서는 함수 타입을 정밀하게 표현하는 도구들을 다뤘습니다.
- 매개변수: 옵셔널 (
?), 디폴트 (= 값), rest (...) - 함수 시그니처에 type alias 붙이기 (
type BinaryOp = (a, b) => number) - 함수 오버로드 — 매개변수에 따라 반환 타입이 달라지는 경우
- 제네릭 입문 —
<T>로 타입을 변수처럼 다루기 - 제약(
T extends ...)과 디폴트(T = string)
제네릭은 살짝 맛만 봤는데, 다음 글에서 본격적으로 들어갑니다. "타입스크립트 기초 강좌 #6 제네릭 깊이"에서는 제약을 더 정교하게 활용하는 법, 다중 타입 매개변수, 제네릭 인터페이스/클래스, keyof 같은 강력한 도구들을 다뤄보겠습니다. 한 번에 다 외울 필요는 없고, 익숙해지는 만큼 코드의 표현력이 함께 늘어나는 영역이에요.