함수와 제네릭으로 타입을 설계하는 법 – “타입이 설계를 주도”하게 만들기
제네릭과 함수 타입으로 입력-출력 관계를 고정해 오용이 불가능한 TypeScript 함수 시그니처를 설계하는 방법을 정리
Posted
By okorion
함수와 제네릭으로 타입을 설계하는 법 – “타입이 설계를 주도”하게 만들기
결론: 좋은 TypeScript 함수는 “입력과 출력의 관계”를 타입으로 고정한다
함수는 로직 이전에 계약(contract) 이다. 제네릭은 그 계약에서 입력과 출력의 관계를 보존하는 도구다. 이 글의 목표는 “컴파일이 통과되는 코드”가 아니라 변경에 강하고 오용이 불가능한 함수 시그니처를 만드는 판단 기준을 제공하는 것이다.
반환 타입은 “의도를 고정할 때”만 명시하고, 나머지는 추론에 맡겨라
결론: 반환 타입은 안정성 도구이지, 장식이 아니다.
언제 명시해야 하나 (판단 기준)
- 공개 API/공용 유틸 함수
- 비동기 함수에서 에러/성공 형태를 고정할 때
- 리팩토링 중 의도 변경을 막고 싶을 때
언제 추론에 맡겨도 되나
- 로컬 헬퍼 함수
- 구현이 곧 타입 의도인 경우
흔한 실수
- 모든 함수에 반환 타입을 붙여 가독성 저하
Promise<any>로 반환 의도 파괴
예제
1
2
3
4
5
6
7
8
9
// ❌ 과한 명시
function add(a: number, b: number): number {
return a + b;
}
// ✅ 의도 고정이 필요한 경우
function fetchUser(): Promise<{ id: string; name: string }> {
return fetch("/user").then(r => r.json());
}
함수 타입과 콜백 타입은 “호출 규약”을 고정하는 도구다
결론: 콜백이 많아질수록 함수 타입을 분리하라.
언제 쓰나
- 이벤트 핸들러
- 비동기 완료 콜백
- 전략 패턴(동작 교체)
흔한 실수
- 인라인 콜백 타입 반복
Function타입 사용(완전한 타입 정보 손실)
예제 (이벤트/비동기)
1
2
3
4
5
type OnComplete<T> = (result: T) => void;
function runAsync<T>(task: () => Promise<T>, onComplete: OnComplete<T>) {
task().then(onComplete);
}
제네릭의 본질은 “관계를 보존하는 타입”이다
결론: 제네릭은 재사용이 아니라 정보 보존을 위해 존재한다.
제네릭이 없으면 타입 정보가 유실되는 예제
1
2
3
4
5
6
7
8
9
// ❌ 입력과 출력의 관계가 사라짐
function identity(value: any) {
return value;
}
// ✅ 입력 타입이 그대로 출력으로 이어짐
function identity<T>(value: T): T {
return value;
}
핵심: 제네릭은 “아무 타입이나”가 아니라 같은 타입을 의미한다.
제약(extends, keyof)은 “잘못된 사용을 금지”하기 위해 쓴다
결론: 제약 없는 제네릭은 any와 다를 바 없다.
extends + keyof가 필요한 예제
1
2
3
4
5
6
7
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "1", age: 20 };
getProperty(user, "id"); // ✅ string
getProperty(user, "email"); // ❌ 컴파일 에러
흔한 실수
<T extends any>같은 무의미한 제약- 제약 없이 내부에서 특정 속성 접근
Generic vs Union: “관계가 있으면 Generic, 없으면 Union”
결론: 선택 기준은 단 하나, 입력과 출력이 연결돼 있는가다.
Union이 맞는 경우
1
2
3
function format(value: string | number): string {
return value.toString();
}
Generic이 맞는 경우
1
2
3
function wrap<T>(value: T): { value: T } {
return { value };
}
흔한 실수
- Union으로 반환 타입을 넓혀 정보 손실
- Generic으로 쓸 필요 없는 단순 분기까지 일반화
함수 오버로드는 “호출자 기준 타입 정확성”을 위해 사용한다
결론: 구현이 아니라 호출 시그니처가 중요한 경우에만 쓴다.
입력에 따라 반환 타입이 바뀌는 예제
1
2
3
4
5
6
7
8
function parse(value: string): string;
function parse(value: number): number;
function parse(value: string | number) {
return value;
}
const a = parse("x"); // string
const b = parse(1); // number
흔한 실수
- 오버로드 없이 union 반환 → 호출부에서 타입 좁히기 지옥
Utility Types는 “새 타입을 만들지 않기 위한 도구”다
결론: 중복 모델을 만들기 시작하면 Utility Types를 검토하라.
DTO / 폼 모델 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User = {
id: string;
name: string;
age: number;
createdAt: Date;
};
// 폼 입력용
type UserForm = Pick<User, "name" | "age">;
// 수정용 DTO
type UserUpdate = Partial<UserForm>;
// API 응답용
type UserResponse = Omit<User, "createdAt">;
자주 쓰는 Utility Types 판단 기준
Partial: 수정/임시 상태Required: 서버 응답 강제Pick/Omit: 역할별 모델 분리Record<K, V>: key-value 맵ReturnType: 기존 함수 계약 재사용
제네릭 남용 패턴 3가지 (읽기 어려워지는 순간)
결론: 제네릭은 많을수록 “똑똑해 보이지만 유지보수는 망가진다”.
- 의미 없는 단일 제네릭
1
function log<T>(value: T): T { return value; } // T 의미 없음
- 중첩 제네릭 폭탄
1
function f<T extends Record<string, U>, U>(x: T): U { ... }
- Union으로 충분한데 Generic 사용
1
function isEmpty<T>(value: T | null | undefined) { ... }
실무 판단 기준 체크리스트
- 반환 타입은 “의도 고정”이 필요한가?
- 제네릭이 입력-출력 관계를 실제로 보존하는가?
- 제약(extends/keyof) 없이 내부에서 특정 속성을 쓰고 있지 않은가?
- Union으로 충분한 문제를 Generic으로 과설계하지 않았는가?
- Utility Types로 중복 모델을 제거했는가?
요약 5줄
- 함수 타입 설계의 핵심은 “입력과 출력의 관계”를 타입으로 고정하는 것이다.
- 반환 타입은 공개 계약에서만 명시하고, 나머지는 추론을 신뢰한다.
- 제네릭은 재사용이 아니라 타입 정보 보존을 위해 존재한다.
- 제약 없는 제네릭은 위험하며, extends/keyof로 오용을 차단해야 한다.
- Utility Types는 타입 중복을 제거하는 실전 도구다.
자기점검 질문 5개
- 내 프로젝트에서 반환 타입을 과도하게 명시한 함수는 없는가?
- 제네릭이 실제로 타입 정보를 보존하고 있는가, 그냥 추상화인가?
- 제약 없이 내부에서 특정 속성에 접근하는 제네릭 함수는 없는가?
- Union으로 충분한 문제를 Generic으로 풀고 있지는 않은가?
- DTO/폼/응답 모델이 불필요하게 복제돼 있지는 않은가?
실전 미션 3개
any또는unknown을 반환하는 함수 하나를 제네릭으로 리팩터링하라.- Union 반환 함수 하나를 오버로드로 바꿔 호출부 타입 정확성을 높여라.
- 중복된 타입 모델 2개를 Pick/Omit/Partial로 통합하라.
This post is licensed under CC BY 4.0 by the author.
