타입스크립트 기초 강좌 #5 함수 타입

18 분 소요

지난 시간에는 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는 모두 같은 타입의 가변 인자를 받습니다.

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이나 튜플 활용:

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

함수의 this 타입 (드물게 사용)

함수 안에서 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;
}

같은 일을 하는데 타입마다 따로 작성해야 하는 게 어색하죠. 제네릭을 쓰면 한 함수로 표현 가능합니다.

제네릭 identity
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가 되어 타입 정보가 사라졌을 텐데, 제네릭 덕에 정밀하게 보존돼요.

배열을 매핑하는 함수:

map 비슷하게
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)

제네릭 타입에 조건을 걸 수 있습니다.

제약 — length가 있는 타입만
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 옵션 하의 변성 검사). 일상적으론 둘 중 어느 쪽이든 상관없어요.

직접 해보기 — 작은 유틸리티

제네릭과 함수 타입을 종합한 작은 유틸 모음.

utils.ts
// 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 TPick<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 같은 강력한 도구들을 다뤄보겠습니다. 한 번에 다 외울 필요는 없고, 익숙해지는 만큼 코드의 표현력이 함께 늘어나는 영역이에요.