오늘은 어려웠던 debounce 함수에 대해 회고를 해보려고 한다.
type Timer = ReturnType<typeof setTimeout> | null;
const debounce = <T extends (...args: Parameters<T>) => ReturnType<T>>(fn: T, timeout = 300) => {
let timer: Timer = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, timeout);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced as T & { cancel: () => void };
};
export default debounce;
그냥 자바스크립트로 구현할 때는 별로 어렵지 않았는데, 타입스크립트를 적용하니 타입 구조가 좀 복잡해져서 이 함수를 꽤 오랫동안 들여다보았다. 나는 특히 두 부분이 어려웠다.
1️⃣ <T extends (...args: Parameters<T>) => ReturnType<T>>(fn: T, timeout = 300) => {
먼저 T extends 문법은 <T extends string>과 같이 사용되고, 이에 따르면 위 코드는
<T extends > 안에 (...args: Parameters<T>) => ReturnType<T>가 들어간 형태이다.
- (...args: Parameters<T>) => ReturnType<T>
- T 함수가 받는 인자들의 타입을 배열(튜플) 형태로 추출. (여기서 T 함수 = fn 인자 함수)
- e.g.) T가 (a: number, b: string)이라면, Parameters는 [number, string]
- ReturnType<T>
- T 함수가 반환하는 값의 타입을 추출. (여기서 T 함수 = fn 인자 함수)
- e.g.) T 함수가 (a: number) => string이라면, ReturnType는 string
여기서 나는 ReturnType의 T가 debounce 함수가 리턴하는 타입을 의미하는 줄 알고 헤맸는데, 그게 아니라 fn 인자 함수를 가리킨다는 걸 몇번의 삽질을 거쳐서야 알았다.
2️⃣ return debounced as T & { cancel: () => void };
그냥 return debounced;로 해도 컴파일 에러가 안 뜨던데, 왜 굳이 as 타입 단언을 해줘야 되는 건지 이해가 안됐다.
그래서 제미나이한테 물어봤더니.. debounced 안에 선언된 cancel 메서드 때문이었다.
cancel 속성은 함수 선언 후에 동적으로 추가되는데, 타입스크립트 컴파일러는 이렇게 동적으로 추가되는 속성을 잘 추적하지 못한다고 한다.
그래서 명시적으로 T 제네릭으로부터 명확한 타입(...args: Parameters<T>) => ReturnType<T>을 상속받고, 추가로 & { cancel: () => void };를 붙여 동적 속성인 cancel 메서드의 타입까지 안전하게 보장해줘야... debounced의 cancel 메서드까지 에러 없이 사용할 수 있는 구조였다.
'✨회고' 카테고리의 다른 글
| 9/1 회고 - zustand와 'use client' (0) | 2025.09.12 |
|---|---|
| 8/29 회고 - headless ui의 Menu vs ListBox | sharp 라이브러리의 용도에 관해 (0) | 2025.09.12 |
| 8/26 회고 - next.js에서의 초기 데이터 fetch + 무한 스크롤 (0) | 2025.09.12 |
| 자바스크립트 리팩토링 회고🔍 - 폼 유효성 검사 (0) | 2025.05.01 |