TypeScript 제네릭: 함수·인터페이스·클래스 제네릭, extends 제약, 고급 패턴과 흔한 실수

이 글의 핵심

제네릭으로 타입을 매개변수처럼 다루는 법, 함수·인터페이스·클래스에 적용하는 방식, extends와 keyof로 제약을 거는 방법, 불필요한 제네릭 같은 흔한 실수를 정리합니다.

들어가며

제네릭이란?

제네릭(Generics)은 타입을 매개변수처럼 넘겨 같은 뼈대로 여러 종류의 값을 안전하게 다루는 기능입니다. 비유(틀): 과자를 찍는 틀은 하나인데, 반죽만 바꿔 초콜릿·쿠키를 모두 찍을 수 있습니다. 제네릭은 함수·클래스에 형태만 맞는 틀을 끼우고, 구체적인 타입은 호출하는 쪽에서 넣는 방식입니다.


제네릭이 필요해지는 순간

TypeScript를 쓰다 보면 처음에는 타입마다 함수를 따로 만들거나, 귀찮아서 any로 넘어가는 시점이 옵니다. fetchUser, fetchProduct, fetchOrder가 URL만 다르고 똑같은 코드를 반복하거나, 공통 함수를 하나로 합치면서 반환 타입을 any로 두는 식입니다. 저도 API 호출 유틸리티를 any로 만들어 두었다가, 응답 필드 이름이 바뀌었는데 컴파일러가 아무것도 알려 주지 않아 화면에 undefined가 찍히는 버그를 겪은 뒤에야 제네릭을 제대로 쓰기 시작했습니다. 제네릭은 “여러 타입에서 동작하는 코드”와 “타입 안전성”을 동시에 얻는 방법이고, 그 핵심은 입력 타입과 출력 타입 사이의 관계를 타입 시스템에 알려 주는 것입니다.

any 대신 제네릭이 필요한 이유

문제 상황

다양한 타입을 받아야 하는 함수를 만들 때 any를 사용하면 타입 안정성을 잃습니다:

// any 사용 (타입 안정성 상실)
function identity(value: any): any {
    return value;
}
const result1 = identity("hello");  // any 타입
const result2 = identity(123);      // any 타입
// 문제점:
// 1. 반환 타입이 any라서 타입 체크가 안 됨
// 2. result1.toFixed()를 호출해도 컴파일 에러가 안 남 (런타임 에러 발생)
// 3. 입력 타입과 출력 타입의 관계를 표현할 수 없음

제네릭 해결

제네릭을 사용하면 타입 안정성을 유지하면서 재사용 가능한 코드를 작성할 수 있습니다:

// 제네릭 사용
// <T>: 타입 매개변수 선언 (T는 관례적 이름, Type의 약자)
// value: T: 매개변수 타입이 T
// : T: 반환 타입이 T (입력과 같은 타입)
function identity<T>(value: T): T {
    return value;
}
// 명시적 타입 지정
const result1 = identity<string>("hello");  // string 타입
const result2 = identity<number>(123);      // number 타입
// 타입 추론 (권장)
// TypeScript가 전달된 값을 보고 T를 자동으로 추론
const result3 = identity("hello");  // T = "hello" (리터럴 타입, string의 하위 타입)으로 추론
const result4 = identity(123);      // T = 123 (리터럴 타입, number의 하위 타입)으로 추론
// 이제 타입 안정성 확보
// result3.toUpperCase();  // ✅ OK - string 메서드
// result3.toFixed();      // ❌ 컴파일 에러 - string에는 toFixed 없음
// result4.toFixed(2);     // ✅ OK - number 메서드

제네릭의 장점:

  1. 타입 안정성: 컴파일 타임에 타입 체크
  2. 재사용성: 하나의 함수로 모든 타입 처리
  3. 가독성: 타입 관계를 명확히 표현
  4. 자동 완성: IDE가 정확한 메서드 제안 제네릭 vs any: | 특성 | 제네릭 (<T>) | any | |-----|--------------|-----| | 타입 안정성 | ✅ 유지 | ❌ 상실 | | 타입 추론 | ✅ 가능 | ❌ 불가 | | 자동 완성 | ✅ 정확 | ❌ 없음 | | 런타임 에러 | ✅ 방지 | ❌ 발생 가능 |

any와 제네릭의 차이는 정보를 버리느냐, 전달하느냐입니다. any를 받는 함수는 “무엇이 들어왔는지 잊어버리는” 함수라서, 문자열을 넣어도 나오는 값은 아무 타입이나 될 수 있습니다. 제네릭 함수는 호출할 때마다 T가 실제 타입으로 결정되고, 그 정보가 반환 타입까지 흘러갑니다. 비슷해 보이는 unknown은 any와 달리 안전하지만, 역시 정보를 잃는다는 점은 같아서 사용하기 전에 타입 검사로 좁혀야 합니다. “받은 타입 그대로 돌려준다”는 관계를 표현하는 방법은 제네릭뿐입니다.

위 코드의 추론 결과를 에디터에서 확인해 보면 result3의 타입이 string이 아니라 "hello"로 표시됩니다. const로 선언했고 T가 반환 타입에 그대로 쓰였기 때문에, TypeScript가 가장 좁은 리터럴 타입으로 추론한 것입니다. "hello"는 string의 하위 타입이라 toUpperCase() 같은 string 메서드는 그대로 쓸 수 있습니다. let result3 = identity("hello")로 선언하면 string으로 넓혀집니다. 이 차이는 대부분 문제가 되지 않지만, 제네릭 함수의 결과를 다른 변수에 재할당하려는데 “Type ‘string’ is not assignable to type ‘“hello”’” 같은 에러가 난다면 이 리터럴 추론이 원인입니다. 그럴 때는 identity<string>("hello")처럼 명시하면 됩니다.

명시적 타입 지정과 추론 중에는 추론을 기본으로 하는 것이 좋습니다. 명시하면 인자와 타입이 이중으로 적혀 중복이 생기고, 인자 타입을 바꿀 때 타입 인자도 함께 고쳐야 합니다. 명시가 필요한 경우는 추론할 근거가 없을 때입니다. 빈 배열이나 null로 시작하는 값처럼 인자에서 타입을 알 수 없으면 T가 unknown이나 never[]로 추론되어, 나중에 값을 넣을 때 에러가 납니다.


함수 제네릭과 여러 타입 매개변수

기본 사용

function getFirstElement<T>(arr: T[]): T | undefined {
    return arr[0];
}
const numbers = [1, 2, 3];
const first = getFirstElement(numbers);  // number | undefined
const strings = ["a", "b", "c"];
const firstStr = getFirstElement(strings);  // string | undefined

여러 타입 매개변수

function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}
const result1 = pair("hello", 123);     // [string, number]
const result2 = pair(true, "world");    // [boolean, string]

화살표 함수

const map = <T, U>(arr: T[], fn: (item: T) => U): U[] => {
    return arr.map(fn);
};
const numbers = [1, 2, 3];
const doubled = map(numbers, (n) => n * 2);        // number[]
const strings = map(numbers, (n) => n.toString()); // string[]

map 예제는 제네릭의 추론이 여러 단계로 연결되는 모습을 보여 줍니다. 먼저 numbers에서 T = number가 정해지고, 그 정보로 콜백의 n이 number로 문맥 타이핑되며, 콜백의 반환값에서 U가 정해져 최종 결과가 U[]가 됩니다. 콜백 매개변수에 타입을 적지 않아도 되는 이유가 이것입니다. 타입 매개변수가 두 개(T, U)인 이유는 입력과 출력이 서로 다를 수 있기 때문이며, 하나로 합치면 number[]를 string[]으로 바꾸는 변환을 표현할 수 없습니다.

.tsx 파일에서 화살표 함수 제네릭을 쓸 때는 알려진 함정이 있습니다. const map = <T>(arr: T[]) => ...라고 쓰면 파서가 <T>를 JSX 태그로 해석해 “JSX element ‘T’ has no corresponding closing tag” 에러가 납니다. <T,>처럼 쉼표를 붙이거나 <T extends unknown>으로 쓰면 제네릭으로 인식됩니다. 위 예제처럼 매개변수가 두 개(<T, U>)면 쉼표가 이미 있어 문제가 없습니다.


인터페이스 제네릭과 API 응답 타입

기본 사용

interface Box<T> {
    value: T;
}
const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "hello" };
// 중첩 제네릭
const boxOfBoxes: Box<Box<number>> = {
    value: { value: 123 }
};

실전 예제: API 응답

interface ApiResponse<T> {
    success: boolean;
    data: T;
    error?: string;
}
interface User {
    id: string;
    name: string;
    email: string;
}
interface Product {
    id: string;
    name: string;
    price: number;
}
const userResponse: ApiResponse<User> = {
    success: true,
    data: {
        id: "U001",
        name: "홍길동",
        email: "[email protected]"
    }
};
const productResponse: ApiResponse<Product[]> = {
    success: true,
    data: [
        { id: "P001", name: "노트북", price: 1000000 },
        { id: "P002", name: "마우스", price: 30000 }
    ]
};

ApiResponse<T>는 모든 응답이 공유하는 껍데기(성공 여부, 에러 메시지)와 응답마다 다른 내용물(data)을 분리합니다. 사용자 응답이든 상품 목록 응답이든 success와 error를 처리하는 코드는 한 번만 작성하고, data를 쓰는 쪽에서만 구체적인 타입을 알게 됩니다. fetchJson<T>(url: string): Promise<ApiResponse<T>> 같은 공통 함수와 조합하면 fetchUser, fetchProduct를 따로 만들 필요가 없어집니다.

다만 이 설계에는 약점이 있습니다. 실패 응답(success: false)에서도 data: T가 필수라서, 에러가 났을 때 무엇을 넣어야 할지 애매하고 결국 null as any 같은 우회가 생깁니다. { success: true; data: T } | { success: false; error: string }처럼 판별 유니온으로 만들면, if (res.success) 검사 뒤에만 data에 접근할 수 있어 성공 여부를 확인하지 않고 데이터를 쓰는 실수를 컴파일러가 막아 줍니다. 또 fetchJson<User>(...)에서 T는 컴파일러에게 하는 약속일 뿐이고, 서버가 실제로 그 형태를 보냈는지 런타임에 검증하지는 않습니다. 외부 입력이라면 zod 같은 런타임 검증 라이브러리로 스키마를 확인하는 편이 안전합니다.


클래스 제네릭

기본 사용

제네릭 클래스를 사용하면 다양한 타입에서 동작하는 자료구조를 만들 수 있습니다:

// Stack<T>: T 타입의 요소를 저장하는 스택 클래스
class Stack<T> {
    // private items: 외부에서 직접 접근 불가
    // T[]: T 타입의 배열
    private items: T[] = [];
    
    // push: 스택에 요소 추가 (LIFO - Last In First Out)
    push(item: T): void {
        // item의 타입은 T로 제한됨
        // numberStack이면 number만, stringStack이면 string만 추가 가능
        this.items.push(item);
    }
    
    // pop: 스택에서 마지막 요소 제거 및 반환
    // 반환 타입: T | undefined (스택이 비어있으면 undefined)
    pop(): T | undefined {
        return this.items.pop();
    }
    
    // peek: 마지막 요소 확인 (제거하지 않음)
    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
    
    // isEmpty: 스택이 비어있는지 확인
    isEmpty(): boolean {
        return this.items.length === 0;
    }
    
    // size: 스택의 크기 반환
    size(): number {
        return this.items.length;
    }
}
// 사용: number 타입 스택
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop());   // 3 (마지막에 추가된 것)
console.log(numberStack.peek());  // 2 (제거하지 않고 확인)
// numberStack.push("hello");     // ❌ 컴파일 에러: string은 number가 아님
// 사용: string 타입 스택
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.pop());  // world
// stringStack.push(123);        // ❌ 컴파일 에러: number는 string이 아님

제네릭 클래스의 장점:

  • 하나의 클래스로 모든 타입 지원
  • 타입별로 별도 클래스를 만들 필요 없음
  • 타입 안정성 유지 (잘못된 타입 추가 방지)
  • 코드 재사용성 극대화 실제 사용 예시:
// 같은 Stack 클래스로 다양한 타입 처리
const taskStack = new Stack<Task>();        // Task 객체 스택
const undoStack = new Stack<Action>();      // Action 객체 스택
const historyStack = new Stack<string>();   // 문자열 스택

클래스 제네릭에서 타입 매개변수는 인스턴스를 만들 때 정해지고 그 인스턴스의 모든 메서드에 공유됩니다. new Stack<number>()로 만든 스택은 push의 인자도, pop의 반환값도 모두 number 기준으로 검사됩니다. 인자 없이 new Stack()이라고만 쓰면 추론할 근거가 없어 T가 unknown이 되고, pop()의 결과를 쓰려 할 때마다 타입 검사가 필요해집니다. 생성자 인자가 없는 제네릭 클래스는 타입을 명시하는 것이 원칙입니다.

알아 둘 제약이 하나 있습니다. static 멤버에서는 클래스의 타입 매개변수를 쓸 수 없습니다. static 멤버는 인스턴스와 무관하게 클래스에 하나만 존재하므로, Stack<number>와 Stack<string>이 공유하는 static 필드의 타입을 T로 정할 방법이 없기 때문입니다. static empty: T[]라고 쓰면 “Static members cannot reference class type parameters” 에러가 납니다. 또 TypeScript의 제네릭은 컴파일 후 지워지므로, 클래스 안에서 value instanceof T처럼 런타임에 T를 검사할 수도 없습니다. C++ 템플릿이나 C# 제네릭과 가장 크게 다른 점입니다.


extends와 keyof로 제약 걸기

extends

// 특정 프로퍼티를 가진 타입만 허용
interface Lengthwise {
    length: number;
}
function logLength<T extends Lengthwise>(value: T): void {
    console.log(value.length);
}
logLength("hello");        // ✅ 5
logLength([1, 2, 3]);      // ✅ 3
// logLength(123);         // ❌ 에러: number에는 length 없음

keyof

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
const user = {
    name: "홍길동",
    age: 25,
    email: "[email protected]"
};
const name = getProperty(user, "name");    // string
const age = getProperty(user, "age");      // number
// const invalid = getProperty(user, "invalid");  // ❌ 에러

extends는 제네릭에게 “아무 타입이나 받지만, 최소한 이 조건은 만족해야 한다”는 하한을 정해 줍니다. T extends Lengthwise는 T가 정확히 Lengthwise여야 한다는 뜻이 아니라 length: number를 가진 타입이면 된다는 뜻이라, 문자열·배열·{ length: 10, name: "x" } 같은 객체까지 모두 통과합니다. 그러면서도 T는 원래 타입을 기억하므로, logLength가 값을 그대로 반환하도록 바꾸면 반환 타입은 Lengthwise가 아니라 넘긴 타입 그대로입니다. 매개변수를 그냥 value: Lengthwise로 선언하면 이 정보가 사라진다는 점이 제약 제네릭과 일반 타입 선언의 차이입니다.

keyof 예제는 두 타입 매개변수 사이의 관계를 표현합니다. K extends keyof T는 “K는 T의 키 중 하나”라는 뜻이고, 반환 타입 T[K]는 그 키에 해당하는 값의 타입입니다. 그래서 같은 함수가 "name"에는 string을, "age"에는 number를 돌려줍니다. 키를 string으로 받았다면 반환 타입은 모든 값 타입의 유니온(string | number)이 되었을 것이고, 오타 난 키도 막을 수 없었을 것입니다. 없는 키를 넘기면 “Argument of type ‘“invalid”’ is not assignable to parameter of type ‘“name” | “age” | “email”’” 에러로 가능한 키 목록까지 보여 줍니다.


예제: 배열 유틸리티·캐시·Promise 래퍼

예제 1: 배열 유틸리티

function chunk<T>(arr: T[], size: number): T[][] {
    const result: T[][] = [];
    for (let i = 0; i < arr.length; i += size) {
        result.push(arr.slice(i, i + size));
    }
    return result;
}
const numbers = [1, 2, 3, 4, 5, 6];
console.log(chunk(numbers, 2));  // [[1, 2], [3, 4], [5, 6]]
const strings = ["a", "b", "c", "d"];
console.log(chunk(strings, 3));  // [["a", "b", "c"], ["d"]]

chunk는 원소 타입을 전혀 몰라도 동작하는 함수라 제네릭이 가장 자연스러운 경우입니다. 원소에 대해 아무 연산도 하지 않고 옮기기만 하므로 제약도 필요 없습니다. 실무에서 쓰려면 size가 0 이하일 때를 막아야 합니다. size가 0이면 i += 0이 되어 루프가 끝나지 않고, 브라우저 탭이 멈추는 무한 루프가 됩니다. 제네릭이 타입은 지켜 주지만 값의 범위까지는 검사하지 않는다는 좋은 예입니다.

예제 2: 캐시 시스템

class Cache<K, V> {
    private store = new Map<K, V>();
    
    set(key: K, value: V): void {
        this.store.set(key, value);
    }
    
    get(key: K): V | undefined {
        return this.store.get(key);
    }
    
    has(key: K): boolean {
        return this.store.has(key);
    }
    
    delete(key: K): boolean {
        return this.store.delete(key);
    }
    
    clear(): void {
        this.store.clear();
    }
}
// 사용
const userCache = new Cache<string, User>();
userCache.set("U001", { id: "U001", name: "홍길동", email: "[email protected]" });
const user = userCache.get("U001");
console.log(user?.name);  // 홍길동

Cache<K, V>는 표준 Map<K, V>를 감싼 것이라 지금은 이점이 크지 않지만, 이런 래퍼는 만료 시간, 최대 크기, 적중률 통계 같은 정책을 나중에 추가할 자리를 만들어 줍니다. get의 반환 타입이 V | undefined인 것이 중요합니다. 캐시에는 값이 없을 수 있으므로 호출하는 쪽에서 ?.나 if 검사를 강제받습니다. has()로 확인한 뒤 get()을 부르더라도 TypeScript는 두 호출의 관계를 모르기 때문에 여전히 undefined를 포함한 타입이 나옵니다. get 결과를 변수에 받아 그 변수로 검사하는 편이 타입 좁히기에도 유리합니다.

객체를 키로 쓸 때는 Map이 참조 동일성으로 비교한다는 점을 기억해야 합니다. cache.set({ id: 1 }, v) 후 cache.get({ id: 1 })은 내용이 같아도 다른 객체라 undefined를 돌려줍니다. 객체를 키로 써야 한다면 문자열 키로 직렬화하거나, 키 객체의 수명이 끝나면 자동으로 정리되는 WeakMap을 고려하세요.

예제 3: Promise 래퍼

class AsyncResult<T> {
    constructor(private promise: Promise<T>) {}
    
    async map<U>(fn: (value: T) => U): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(Promise.resolve(fn(value)));
    }
    
    async flatMap<U>(fn: (value: T) => Promise<U>): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(fn(value));
    }
    
    async unwrap(): Promise<T> {
        return await this.promise;
    }
}
// 사용
const result = new AsyncResult(Promise.resolve(10));
result
    .map((x) => x * 2)
    .then((r) => r.unwrap())
    .then((value) => console.log(value));  // 20

이 래퍼에서 볼 부분은 메서드 수준의 타입 매개변수 U입니다. 클래스의 T는 인스턴스가 만들어질 때 정해지고, 메서드의 U는 그 메서드를 호출할 때마다 콜백의 반환 타입으로 새로 정해집니다. 그래서 AsyncResult<number>에서 .map(x => x.toString())을 호출하면 AsyncResult<string>이 나옵니다. map과 flatMap의 차이는 콜백이 일반 값을 반환하느냐 Promise를 반환하느냐로, flatMap이 없다면 AsyncResult<Promise<U>>처럼 겹겹이 싸인 타입이 생깁니다.

설계로 보면 이 예제는 개선할 여지가 있습니다. map이 async라 Promise<AsyncResult<U>>를 반환하기 때문에, 사용 예처럼 .then(r => r.unwrap())을 거쳐야 하고 .map(...).map(...)으로 이어서 호출할 수 없습니다. map을 async 없이 return new AsyncResult(this.promise.then(fn));로 만들면 AsyncResult<U>를 바로 반환해 체이닝이 자연스러워집니다. 사실 Promise 자체가 이미 then으로 map과 flatMap을 모두 수행하므로, 실무에서는 이런 래퍼 대신 async/await를 쓰는 경우가 대부분입니다. 이 예제는 메서드 제네릭의 동작을 보여 주는 용도로 이해하면 됩니다.


조건부 타입과 매핑 타입 맛보기

조건부 타입

type IsString<T> = T extends string ? true : false;
type A = IsString<string>;   // true
type B = IsString<number>;   // false

매핑 타입

// 내장 Readonly와 같은 구현 (이름 충돌을 피해 MyReadonly로 정의)
type MyReadonly<T> = {
    readonly [K in keyof T]: T[K];
};
interface User {
    name: string;
    age: number;
}
type ReadonlyUser = MyReadonly<User>;
// { readonly name: string; readonly age: number; }

조건부 타입과 매핑 타입은 “타입을 입력받아 타입을 출력하는 함수”라고 보면 됩니다. 제네릭 함수가 값을 변환하듯, 제네릭 타입 별칭은 타입을 변환합니다. IsString<T>는 T가 string에 할당 가능한지 검사해 true나 false 타입을 돌려주고, MyReadonly<T>는 T의 모든 키를 돌며 readonly를 붙인 새 타입을 만듭니다. 표준 유틸리티 타입(Partial, Pick, ReturnType 등)이 모두 이 두 가지로 구현되어 있으며, 자세한 내용은 유틸리티 타입 글에서 다룹니다. 이 예제의 이름을 MyReadonly로 둔 이유는, 모듈이 아닌 스크립트 파일에서 type Readonly<T>를 다시 선언하면 내장 타입과 충돌해 “Duplicate identifier ‘Readonly’” 에러가 나기 때문입니다.


제약 없는 프로퍼티 접근과 불필요한 제네릭

실수 1: 제네릭 제약 없이 프로퍼티 접근

// ❌ 잘못된 사용
function getLength<T>(value: T): number {
    return value.length;  // 에러: T에 length가 있다는 보장 없음
}
// ✅ 올바른 사용
function getLength<T extends { length: number }>(value: T): number {
    return value.length;
}

첫 번째 코드는 “Property ‘length’ does not exist on type ‘T’“(TS2339) 에러를 냅니다. 제약이 없는 T는 number나 boolean일 수도 있으므로, 컴파일러는 T가 모든 타입에 공통인 것만 가진다고 봅니다. 그런데 사실 이 경우에는 제네릭 자체가 필요 없습니다. 반환 타입이 number로 고정이라 T가 입력과 출력을 연결하지 않으므로, function getLength(value: { length: number }): number로 충분합니다. 제네릭은 타입 매개변수가 두 곳 이상에 등장해 관계를 만들 때 의미가 있다는 것이 다음 실수 2와 이어지는 원칙입니다.

실수 2: 불필요한 제네릭

// ❌ 불필요한 제네릭
function log<T>(message: string): void {
    console.log(message);
}
// ✅ 제네릭 제거
function log(message: string): void {
    console.log(message);
}

불필요한 제네릭의 더 교묘한 형태는 반환 타입에만 등장하는 제네릭입니다. function parse<T>(json: string): T { return JSON.parse(json); }는 제네릭처럼 보이지만, T가 입력과 아무 관련이 없어서 호출자가 parse<User>(text)라고 쓰면 검증 없이 그냥 User라고 믿어 줍니다. 사실상 as User 타입 단언을 함수 안에 숨긴 것이라, 제네릭의 안전성을 기대하고 쓰면 오히려 위험합니다. 이런 함수는 unknown을 반환하고 호출자가 검증하게 하거나, 스키마를 인자로 받아 검증과 타입을 함께 얻도록 설계하는 편이 정직합니다.


제네릭 요약

  1. 제네릭: 타입을 매개변수화
  2. 함수: function fn<T>(value: T): T
  3. 인터페이스: interface Box<T>
  4. 클래스: class Stack<T>
  5. 제약: <T extends Type>
  6. keyof: 객체 키 타입

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 타입 매개변수를 선언했는데 실제로 쓰지 않으면 무엇이 문제인가요?

A. log(message: string)처럼 T가 매개변수나 반환 타입 어디에도 연결되지 않으면 타입 안전성은 전혀 늘지 않고 시그니처만 복잡해집니다. 제네릭은 입력과 출력, 또는 여러 매개변수 사이의 타입 관계를 표현할 때 의미가 있으므로 그런 관계가 없다면 제거하는 것이 맞습니다. 반대로 value.length처럼 T의 프로퍼티에 접근해야 한다면 T extends { length: number }처럼 제약을 걸어야 컴파일 에러가 사라집니다.