제너릭(Generic)
"함수에서 타입을 변수처럼 쓰는 것"이라고 정의하면 어렵게 느껴질 수 있다.
쉽게 말하면 이렇다. 보통 함수는 인자로 값을 받는다. 제너릭은 타입도 인자처럼 받을 수 있게 해 준다.
`any`를 쓰면 안 되는 이유는 명확하다.
`any`를 쓰는 순간 TypeScript가 타입 정보를 포기한다. 넣을 때도 `any`, 나올 때도` any`.
그냥 JavaScript 쓰는 것과 다를 게 없어진다.
제너릭을 쓰면 다르다.
function getFirst<T>(arr: T[]): T {
return arr[0];
}
getFirst([1, 2, 3]); // T가 number로 추론됨 → number 반환
getFirst(["a", "b"]); // T가 string으로 추론됨 → string 반환
`T`는 "아직 정해지지 않은 타입"을 나타내는 자리다. 함수를 호출하는 순간 TypeScript가 인자를 보고 `T`가 뭔지 자동으로 추론해 준다. 넣은 타입이 그대로 나오는 것이다.
Constraint — "T가 최소한 이건 가져야 해"
제너릭은 기본적으로 어떤 타입이든 다 받을 수 있다. 그런데 때로는 "id가 있는 타입만 받고 싶어"처럼 조건을 걸고 싶을 때가 있다. 이럴 때 extends로 constraint를 건다.
function findById<T extends { id: number }>(items: T[], id: number): T | null {
return items.find(item => item.id === id) ?? null;
}
`T extends { id: number }`는 "T는 반드시 `id: number`를 가지고 있어야 해"라는 조건이다.
이 조건을 만족하지 않는 타입은 아예 넣을 수 없다. 타입 수준에서 막아버리는 것이다.
예를 들어 이렇게 쓸 수 있다.
type User = { id: number; name: string };
type Product = { id: number; price: number };
findById([{ id: 1, name: "찰리" }], 1); // User는 id: number 있음
findById([{ name: "찰리" }], 1); // id 없어서 에러
Type Predicate
TypeScript는 함수 이름을 보지 않는다. 선언된 타입만 본다.
이게 무슨 말이냐면, 함수 이름이 `isAdmin`이어도 TypeScript 입장에서는 그냥 `boolean`을 반환하는 함수일 뿐이다.
"이 함수가 true를 반환하면 Admin이야"라는 사실을 TypeScript는 모른다.
type Person = { name: string };
type Admin = { name: string; role: "admin" };
// boolean만 쓰면 → filter 결과가 Person[] (타입이 좁혀지지 않음)
function isAdmin(person: Person): boolean {
return (person as Admin).role === "admin";
}
const admins = people.filter(isAdmin); // Person[]
filter 결과가 여전히 `Person []`이다. TypeScript가 isAdmin이 뭘 걸러내는지 모르기 때문이다.
person is Admin을 쓰면 달라진다.
// person is Admin 쓰면 → filter 결과가 Admin[] (타입이 좁혀짐)
function isAdmin(person: Person): person is Admin {
return (person as Admin).role === "admin";
}
const admins = people.filter(isAdmin); // Admin[]
person is Admin은 일종의 계약서다.
"이 함수가 true를 반환하면, 그 값은 Admin이야"라고 TypeScript에게 명시적으로 알려주는 것이다.
이걸 Type Predicate라고 한다.
함수 오버로드(Function Overload)
"number를 넣으면 number를 반환하고, string을 넣으면 string을 반환한다." 이런 걸 표현하고 싶을 때 쓴다.
TypeScript는 구현부 본문을 보지 않는다. 선언된 타입만 본다.
그래서 아래처럼 구현 함수 하나만 있으면 TypeScript가 정확한 반환 타입을 알 수 없다.
// 이것만 있으면...
function double(x: number | string): number | string {
if (typeof x === 'number') return x * 2;
return x + x;
}
const a = double(5); // number | string (number인 걸 모름)
const b = double("hi"); // number | string (string인 걸 모름)
그래서 계약서(오버로드 시그니처)를 위에 따로 써줘야 한다.
function double(x: number): number; // 계약서 1
function double(x: string): string; // 계약서 2
function double(x: number | string): number | string { // 실제 구현
if (typeof x === 'number') return x * 2;
return x + x;
}
const a = double(5); // number
const b = double("hi"); // string
TypeScript는 계약서(오버로드 시그니처)를 순서대로 보고 맞는 타입을 찾아준다.
구현부는 외부에서 직접 호출할 수 없다. 계약서를 통해서만 함수를 쓸 수 있는 것이다.
Async / Promise
`async`를 붙이면 반환값이 자동으로 `Promise`로 감싸진다. 굳이 `Promise.resolve()`로 감쌀 필요가 없다.
async function fetchUser(): Promise<User> {
return { id: 1, name: "찰리" }; // 자동으로 Promise<User>가 됨
}
await는 반대로 Promise를 벗겨낸다.
const user = await fetchUser(); // Promise<User> → User
이 둘을 같이 쓰면 비동기 코드를 마치 동기 코드처럼 읽을 수 있어서 훨씬 직관적이다.
catch에서 e는 왜 unknown인가?
try {
await fetchUser();
} catch (e) {
// e는 unknown
if (e instanceof Error) {
console.error(e.message);
}
}
e가 unknown인 이유가 있다. JavaScript에서는 throw로 뭐든 던질 수 있다.
throw new Error("에러"); // Error 객체
throw "에러 발생"; // string
throw 42; // number
throw { code: 500 }; // 객체
어떤 타입이 날아올지 TypeScript가 알 수 없으니 unknown으로 받는 것이다. any였다면 그냥 넘어가겠지만, unknown이라서 타입을 확인하고 쓰도록 강제한다. 그래서 instanceof Error로 확인한 다음에 써야 한다.
"TypeScript는 선언된 타입만 본다"는 원칙에서 출발한다는 게 재밌다.
함수 이름을 믿지 않고, 구현부를 믿지 않고, 오직 선언된 타입만 믿는다.
그 원칙을 이해하고 나면 나머지가 자연스럽게 연결된다.
'프론트엔드 > Language' 카테고리의 다른 글
| [JavaScript] 실행컨텍스트 & 클로저 (0) | 2026.03.19 |
|---|---|
| [JavaScript] 스코프(Scope) (0) | 2026.03.09 |
