جنریک در TypeScript: چرا کد بدون Generics قابل استفادهی مجدد نیست؟
جنریک در TypeScript (Generics) چرا کد بدون آن قابل استفاده مجدد نیست؟ راهنمای عملی constraint، keyof، default type و الگوهای واقعی در React، API و ساختارهای داده.
اولین باری که با Generics در تایپ اسکریپت روبرو شدم، یک تابع ساده بودم که میخواستم برای انواع مختلف داده کار کند. بدون Generics، دو راه داشتم: یا برای هر تایپ یک تابع جدا بنویسم، یا از any استفاده کنم و تایپچکینگ را بهکل قربانی کنم. هر دو راه غلط بود. راه سوم که آن روز یاد گرفتم، کل نگاه من به طراحی کد را تغییر داد: Generics به من اجازه میداد که یک تابع یا یک ساختار داده بنویسم، آن را با تایپهای مختلف استفاده کنم، و همچنان تایپچکینگ دقیق TS را حفظ کنم. این مقاله حاصل چند سال تجربه با این مفهوم است — از پروژههای کوچک React تا کتابخانههای عمومی و API wrapperهای پیچیده.
Generics در TypeScript یکی از پرکاربردترین و در عین حال کمدرکترین ویژگیهای این زبان است. برای درک جایگاه آن، باید بدانید که TS در واقع دو هدف متضاد را با هم دنبال میکند: بازاستفادهپذیری کد (که Generics را ضروری میکند) و امنیت تایپ (که در استفادهی بیرویه از any قربانی میشود). Generics، پلی است که بین این دو هدف میسازد. اگر در مسیر آموزش تایپ اسکریپت از صفر هستید و مقالات تایپ ها در تایپ اسکریپت و اینترفیس در تایپ اسکریپت را خواندهاید، این نوشته قدم بعدی منطقی شماست.
Generics دقیقاً چیست و چه مشکلی را حل میکند؟
Generics یک مکانیزم برای نوشتن کدی است که «با هر تایپی» کار میکند، اما همچنان تایپ را بهعنوان یک پارامتر در خود نگه میدارد. در نگاه اول، این تعریف شبیه any بهنظر میرسد. اما تفاوت بنیادی وجود دارد: any تایپچکینگ را خاموش میکند، Generics آن را حفظ میکند.
یک مثال ساده که این تفاوت را روشن میکند:
// بدون Generics - باید دو تابع جدا داشته باشیم
function firstNumber(arr: number[]): number {
return arr[0];
}
function firstString(arr: string[]): string {
return arr[0];
}
// با any - تایپچکینگ خاموش میشود
function firstAny(arr: any[]): any {
return arr[0];
}
// با Generics - هم بازاستفادهپذیر، هم امن
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([1, 2, 3]); // num: number
const str = first(["a", "b"]); // str: string
تفاوت آخرین نسخه با any در این است که TS دقیقاً میداند num یک number است و str یک string. این یعنی auto-complete دقیق، بررسی خطا در لحظهی نوشتن، و امکان refactoring مطمئن.
سه سناریوی واقعی که Generics بهطور مستقیم اثر میگذارد:
- توابع و Utilityهای عمومی:
map،filter،reduce،first،last،groupBy. یک بار نوشتن، بینهایت استفاده با تایپهای مختلف. - ساختارهای داده: صف، پشته، درخت، Cache، Graph. یک پیادهسازی، استفاده با تایپ دلخواه.
- API Layer و Response Wrapper: تایپدهی به پاسخهای API که ساختار مشترک دارند اما data متفاوت.
Generics در TypeScript مثل قالبهای صنعتی در تولید هستند: یکبار طراحی میشوند، اما با مواد مختلف قابل استفادهاند — با حفظ کیفیت و شکل.
اولین Generic: از تابع تا ساختار داده
سادهترین Generics یک تابع است:
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // num: number
const str = identity("hello"); // str: string
const obj = identity({ id: 1 }); // obj: { id: number }
حرف T در اینجا یک نام قراردادی است (مخفف Type) اما میتوانید هر نامی استفاده کنید. در پروژههای واقعی، نامهای توصیفی بیشتر استفاده میشوند:
function getProperty<TObject, TKey extends keyof TObject>(
obj: TObject,
key: TKey
): TObject[TKey] {
return obj[key];
}
const user = { id: 1, name: "Ali", email: "ali@example.com" };
const name = getProperty(user, "name"); // name: string
const id = getProperty(user, "id"); // id: number
این مثال، دو مفهوم پیشرفته را نشان میدهد که در بخشهای بعدی توضیح میدهم: Constraints (TKey extends keyof TObject) و Indexed Access Types (TObject[TKey]).
در تیمها، نامگذاری Generics را جدی میگیرم. قاعدهی من: برای یک پارامتر Generic ساده، T کافی است. برای دو پارامتر، T و U. برای سه پارامتر و بیشتر، از نامهای توصیفی مثل TValue، TKey، TResult استفاده کنید. این قاعده، خوانایی کد را در پروژههای بزرگ بهطور محسوس افزایش میدهد.
Constraints: محدود کردن دامنه تایپ
Generics آزاد است — هر تایپی میتواند به آن پاس شود. اما در سناریوهای واقعی، اغلب میخواهیم فقط تایپهای خاصی را بپذیریم. اینجاست که Constraints وارد میشوند:
// بدون constraint - هر چیزی قبول میشود
function getLength<T>(value: T): number {
return value.length; // خطای کامپایل! T ممکن است length نداشته باشد
}
// با constraint
function getLength<T extends { length: number }>(value: T): number {
return value.length; // حالا امن است
}
getLength("hello"); // OK
getLength([1, 2, 3]); // OK
getLength(42); // خطای کامپایل!
Constraint میگوید «T میتواند هر چیزی باشد، به شرطی که حداقل این ویژگیها را داشته باشد». این ابزار، هم بازاستفادهپذیری را حفظ میکند و هم امنیت را.
Constraint با keyof
یکی از پرکاربردترین الگوهای Constraints، استفاده از keyof است:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
const users = [
{ id: 1, name: "Ali", role: "admin" },
{ id: 2, name: "Sara", role: "user" }
];
const names = pluck(users, "name"); // names: string[]
const ids = pluck(users, "id"); // ids: number[]
const roles = pluck(users, "role"); // roles: string[]
این الگو، پایهی بسیاری از توابع Utility در کتابخانههایی مثل Lodash و Ramda است. در پروژههای خودم، تابع pluck را در utilityهای مشترک داشتهام و در جای مختلف از آن استفاده کردهام. مطالعهی بیشتر در Generics در تایپ اسکریپت.
Constraint با Union
function createStatus<T extends "pending" | "approved" | "rejected">(
status: T
): { status: T } {
return { status };
}
createStatus("pending"); // OK
createStatus("unknown"); // خطای کامپایل
این الگو برای توابعی که فقط مقادیر مشخصی میپذیرند، بسیار مفید است. جایگزین بهتر برای Union مستقیم در ورودی تابع است.
keyof و نقش آن در دسترسی امن
keyof یک اپراتور TypeScript است که تمام کلیدهای یک تایپ را بهعنوان یک Union Type برمیگرداند:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User;
// "id" | "name" | "email"
ترکیب keyof با Generics، الگوهای پرقدرتی میسازد:
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
const user = { id: 1, name: "Ali", email: "ali@x.com", password: "..." };
const safeUser = pick(user, ["id", "name", "email"]);
// safeUser: { id: number; name: string; email: string }
این تابع، در واقع همان Pick داخلی TS را بازسازی میکند. اما نکتهی مهم این است که TS دقیقاً میداند چه تایپی برگردانده شده — این همان قدرت Generics است.
در پروژهای که یک API Layer داشتیم، الگوی keyof باعث شد که هم تایپ دقیق داشته باشیم و هم امکان جابهجایی بین تایپهای مختلف را. اگر تایپ User تغییر میکرد، تمام فراخوانیهای pick در کامپایلر بررسی میشدند. مطالعهی بیشتر در تایپ ها در تایپ اسکریپت.
Default Type Parameters
در بعضی سناریوها، میخواهید یک تایپ Generic داشته باشید که اگر کاربر تایپ خاصی پاس نداد، یک مقدار پیشفرض استفاده شود:
interface ApiResponse<T = unknown> {
status: number;
message: string;
data: T;
}
// استفاده بدون تایپ مشخص
const response1: ApiResponse = { ... }; // data: unknown
// استفاده با تایپ مشخص
const response2: ApiResponse<User> = { ... }; // data: User
Default Types در پروژههای واقعی برای API Layerها پرکاربرد هستند. در پروژهای که یک کلاینت HTTP مینوشتیم، تایپ پایه به این شکل تعریف شد:
type HttpResult<T = void> =
| { success: true; data: T }
| { success: false; error: string };
پس از این الگو، همهی فراخوانیهای HTTP یک شکل واحد داشتند، اما دادهی هر endpoint، تایپ خودش را داشت. مطالعهی بیشتر در تایپ اسکریپت با Node.js.
Generic Classes و Interfaces
Generics فقط برای توابع نیست. کلاسها و interfaceها هم میتوانند Generic باشند:
Generic Class
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.pop(); // top: number | undefined
const userStack = new Stack<User>();
userStack.push({ id: 1, name: "Ali" });
این الگو، پایهی تمام ساختارهای داده در TypeScript است. هر بار که یک Cache، Queue یا Graph مینویسید، یک Generic Class مناسبترین انتخاب است.
Generic Interface
interface Repository<T, ID = number> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
}
class UserRepository implements Repository<User, string> {
async findById(id: string): Promise<User | null> { /* ... */ }
async findAll(): Promise<User[]> { /* ... */ }
async save(user: User): Promise<User> { /* ... */ }
async delete(id: string): Promise<void> { /* ... */ }
}
الگوی Repository یکی از پرکاربردترین الگوهای معماری است. با Generics، میتوانید یک interface واحد داشته باشید که برای تمام نهادهای دامنه کار کند. مطالعهی بیشتر در اینترفیس در تایپ اسکریپت.
Generics در Utility Types
بسیاری از Utility Types داخلی TS، در واقع Generic هستند. درک این نکته، عمق استفاده از آنها را بیشتر میکند:
| Utility | معنی Generics |
|---|---|
Partial<T> | همهی خاصیتهای T را اختیاری میکند |
Required<T> | همهی خاصیتهای T را اجباری میکند |
Readonly<T> | همهی خاصیتهای T را readonly میکند |
Pick<T, K> | زیرمجموعهی خاصیتهای T را انتخاب میکند |
Omit<T, K> | خاصیتهای مشخصی را از T حذف میکند |
Record<K, V> | شیء با کلیدهای K و مقادیر V |
Exclude<T, U> | حذف تایپهای U از Union T |
Extract<T, U> | نگهداشتن تایپهای U از Union T |
ReturnType<F> | تایپ بازگشتی تابع F |
Awaited<P> | تایپ resolveشدهی Promise |
ترکیب این Utilityها با Generics خودتان، الگوهای پیچیدهای میسازد. مثلاً یک API client که برای هر endpoint، هم ورودی و هم خروجی را تایپدهی میکند:
type ApiClient = {
get<T>(url: string): Promise<T>;
post<TInput, TOutput>(url: string, data: TInput): Promise<TOutput>;
put<TInput, TOutput>(url: string, data: TInput): Promise<TOutput>;
delete<T>(url: string): Promise<T>;
};
این الگو، در پروژههای فولاستک که با frontend و backend در یک monorepo کار میکنند، تفاوت محسوسی در سرعت توسعه میسازد. مطالعهی بیشتر در آموزش تایپ اسکریپت از صفر.
الگوهای واقعی در React، API و Data Structures
سه سناریوی واقعی که Generics بیشترین اثر را داشته:
۱. React با Generics
در یک پروژهی React، یک کامپوننت Table داشتم که با دادههای مختلف کار میکرد. با Generics، تایپ آن به این شکل نوشته شد:
interface TableProps<T> {
data: T[];
columns: Array<{
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}>;
}
function Table<T>({ data, columns }: TableProps<T>) {
// ...
}
نتیجه: هر جا از Table استفاده میکردیم، TS دقیقاً میدانست چه کلیدهایی معتبرند و چه تایپی دارند. اگر ستون اشتباهی وارد میشد، کامپایلر خطا میداد. مطالعهی موازی در تایپ اسکریپت با React.
۲. API Wrapper با Generics
در یک پروژهی فروشگاهی که با چند API خارجی کار میکرد، یک wrapper برای HTTP نوشتم که تایپ دقیق برگرداند:
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(endpoint, options);
if (!response.ok) throw new Error(response.statusText);
return response.json() as Promise<T>;
}
interface Product {
id: number;
name: string;
price: number;
}
const product = await fetchApi<Product>("/api/products/1");
// product: Product
این الگو حجم کد را در لایهی API بهطور محسوس کاهش میدهد و همزمان تایپچکینگ را حفظ میکند. مطالعهی بیشتر در تایپ اسکریپت با Node.js.
۳. Data Structure با Generics
در یک پروژهی داشبورد تحلیلی، از یک Cache Generic استفاده کردیم که میتوانست هر نوع دادهای را نگه دارد:
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private capacity: number) {}
get(key: K): V | undefined {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value); // به انتها منتقل میشود
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) this.cache.delete(key);
if (this.cache.size >= this.capacity) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}
const userCache = new LRUCache<string, User>(100);
این ساختار در پروژههای واقعی، تفاوت محسوسی در کارایی ایجاد میکند. مطالعهی موازی در بهینه سازی جاوااسکریپت.
اشتباهاتی که در پروژهها دیدم
- Generics بیدلیل: در بعضی پروژهها، توسعهدهندهها برای تابعی که فقط با یک تایپ کار میکند، Generic مینویسند. اگر تابع شما فقط با string کار میکند، از string استفاده کنید نه Generic. Generics برای جایی است که واقعاً با چند تایپ کار میکنید.
- پارامترهای بیشازحد: در پروژهای، تابعی با پنج پارامتر Generic دیدم. این نشانهی طراحی بد است. اگر بیش از دو یا سه پارامتر Generic دارید، احتمالاً باید تابع را به چند تابع کوچکتر تقسیم کنید یا از یک interface استفاده کنید.
- فراموش کردن Constraints: بدون Constraints، TS اجازه میدهد هر تایپی پاس شود و در بدنهی تابع با خطا مواجه میشوید. همیشه قبل از استفاده از خاصیت یا متد روی T، آن را Constraint کنید.
- نامگذاری بد: استفاده از
T1،T2،T3در پروژههای بزرگ، خوانایی کد را پایین میآورد. از نامهای توصیفی مثلTValue،TKey،TResultاستفاده کنید. - Generics بهجای Composition: در بعضی سناریوها، ترکیب سادهی توابع بهتر از یک تابع Generic پیچیده جواب میدهد. اگر یک تابع Generic با ۵ Constraint دارید که همهی پیچیدگی را اضافه میکند، بازبینی کنید که آیا Composition سادهتر نیست.
- استفاده از any بهعنوان Generic:
function foo<T = any>()یعنی Generics را خاموش میکنید. اگر بهanyنیاز دارید، بهتر است صریحاً آن را بپذیرید و بگویید چرا، نه اینکه پشت Generic پنهانش کنید. - نادیده گرفتن Default Types: در API Clientها و توابع Utility، Default Type Parameters باعث میشود فراخوانیها سادهتر شوند. استفاده نکردن از آن، حجم کد را در محل استفاده بیشتر میکند.
- نادیده گرفتن کارایی کامپایل: Generics بسیار پیچیده، زمان کامپایل TS را افزایش میدهد. در پروژههای بزرگ، اگر زمان build بهطور محسوس افزایش یافته، یکی از دلایل میتواند Generics بیرویه باشد.
بخشی از این اشتباهات در خطاهای رایج تایپ اسکریپت و اشتباهات رایج توسعه هم آمده است.
لایهای پایینتر از سینتکس Generics
اینجا وارد لایهای میشوم که در پروژههای معمولی به آن نگاه نمیشود اما برای مهندسان پلتفرم و توسعهدهندههای ارشد اهمیت دارد. آنچه سیستم تایپ TS با Generics شما میکند، در پنج مفهوم خلاصه میشود:
- Type Parameter Substitution و کامپایل: وقتی شما
first<number>([1,2,3])مینویسید، TS در زمان کامپایل، تمامTها را باnumberجایگزین میکند و یک نسخهی تخصصی از تابع میسازد. اما نکتهی مهم این است که این جایگزینی در کامپایل انجام میشود، نه در runtime. یعنی خروجی JS، دقیقاً همان کدی است که اگر Generics نداشتید. این ویژگی «Type Erasure» نام دارد و یک پیامد عملی دارد: Generics هیچ هزینهی runtime ندارد. مطالعهی موازی در تفاوت تایپ اسکریپت و جاوااسکریپت. - Type Inference در Generics: TS میتواند بسیاری از اوقات تایپ Generic را از context استنتاج کند. مثلاً در
const x = first([1,2,3])، بدون نوشتن<number>، TS خودشT = numberاستنتاج میکند. این ویژگی حجم کد را کاهش میدهد اما گاهی میتواند باعث تایپهای غیرمنتظره شود. اگر استنتاج ناخواسته اتفاق افتاد، تایپ را صریح بنویسید:first<string | number>([...]). مطالعهی موازی در تایپ ها در تایپ اسکریپت. - Higher-Order Types و المانهای پیشرفته: در کتابخانههای عمومی مثل React و tRPC، از سطح پیشرفتهی Generics استفاده میشود که شامل Conditional Types، Mapped Types و Template Literal Types است. این سطح از Generics در پروژههای معمولی لازم نیست، اما اگر کتابخانهی عمومی مینویسید یا با کتابخانههای پیچیده کار میکنید، تسلط بر آنها ضروری است. مطالعهی موازی در مفاهیم پیشرفته جاوااسکریپت.
- Generics و Variance: در سیستم تایپ TS، مفهوم Variance (کوواریانس و کنتراواریانس) در Generics نقش کلیدی دارد. اگر یک Generic بهعنوان پارامتر ورودی تابع استفاده شود و در جای دیگری بهعنوان پارامتر خروجی، رفتار TS در تعیین سازگاری متفاوت است. این لایه در طراحی کتابخانههای عمومی اثر مستقیم دارد. مطالعهی موازی در آموزش تایپ اسکریپت از صفر.
- Generics و Type-Level Programming: در سطح پیشرفته، Generics با Conditional Types ترکیب میشود و اجازه میدهد که «منطق» را در سطح تایپ بنویسید. مثلاً یک تایپ
IsArray<T>که اگر T آرایه باشد،trueبرمیگرداند و در غیر این صورتfalse. این سطح از استفاده در کتابخانههای عمومی مثل Zod و Prisma بسیار دیده میشود. مطالعهی موازی در تایپ اسکریپت با Node.js و تایپ اسکریپت با React.
یک تجربهی واقعی از پروژهای که با Type Inference در Generics مواجه شدیم: در یک کامپوننت React، تابعی داشتیم که آرایهای میگرفت و یکی از آیتمها را برمیگرداند. تایپ Generic بهطور خودکار استنتاج میشد و در اکثر مواقع درست کار میکرد، اما در یک سناریوی خاص که آرایه خالی بود، T به unknown تبدیل میشد و downstream code با خطای کامپایل مواجه میشد. راهحل: نوشتن صریح تایپ Generic در محل استفاده و کمک به استنتاج. یک خط تغییر، خطای غیرقابلدرک در جای دیگر را حل کرد.
اگر روی پروژههای وردپرسی هستید و میخواهید این لایهها را در development pipeline خود اعمال کنید، پیشنهاد میکنم ابتدا به توسعه وردپرس از صفر نگاهی بیندازید. برای مطالعهی موازی با استانداردها و معماری، استانداردهای HTML و CSS و CSS مدرن از Flexbox تا Grid دید وسیعتری میدهند. برای درک این لایه در چارچوب کارایی، بهینه سازی جاوااسکریپت و بهینهسازی سرعت سایت منابع کلیدی هستند. اگر روی موضوع تست و کیفیت کد متمرکز هستید، گیت در وردپرس و ابزارهای CI/CD را هم ببینید.
Generics در TypeScript مثل قالبهای ریختهگری در کارخانه هستند: یکبار طراحی میشوند، با مواد مختلف کار میکنند، و درست طراحی شدنشان، تفاوت بین یک محصول معمولی و یک محصول حرفهای است.
خط آخر این مسیر
Generics در TypeScript را میتوان در یک جمله خلاصه کرد: «ابزاری برای نوشتن کد بازاستفادهپذیر بدون قربانی کردن امنیت تایپ.» سه درس که از این مسیر با خودم بردم:
- Generics را برای جایی نگه دارید که واقعاً چند تایپ دارد. اگر یک تابع یا کلاس فقط با یک تایپ کار میکند، استفاده از Generics فقط پیچیدگی اضافه میکند. اما اگر واقعاً با انواع مختلف داده کار میکنید، Generics جایگزین
anyاست و تفاوتش در امنیت تایپ، چند برابر است. - Constraints را جدی بگیرید. بدون Constraints، تابع Generic شما خطای کامپایل میدهد چون TS نمیداند T چه ویژگیهایی دارد. Constraints زبانی است که به TS میگوید «T این ویژگیها را دارد» و بدنهی تابع شما را امن میکند.
- نامگذاری در Generics به خوانایی کمک میکند. استفاده از
T،UوVبرای Genericsهای ساده، و ازTValue،TKeyوTResultبرای Genericsهای پیچیده، تفاوت محسوسی در خوانایی کد در پروژههای بزرگ میسازد.
مسیر یادگیری فرانتاند با این نوشته تمام نمیشود. اگر میخواهید مرحلهی بعدی را بردارید، آموزش تایپ اسکریپت از صفر، تایپ ها در تایپ اسکریپت و اینترفیس در تایپ اسکریپت سه قدم منطقی بعدی هستند. اگر روی فریمورکها متمرکز هستید، تایپ اسکریپت با React، تایپ اسکریپت با Node.js و ماژول ها در تایپ اسکریپت دید وسیعتری میدهند. اگر هم به سمت خطاها و دیباگ میروید، خطاهای رایج تایپ اسکریپت و تنظیمات tsconfig منابع کلیدی هستند.
Generics یکی از آن موضوعاتی است که تا زمانی که در پروژهای بهطور جدی با آن روبرو نشوید، عمقش را درک نمیکنید. اگر شما هم در یک پروژه به الگوی خاصی از Generics رسیدهاید که بهرهوری را دوچندان کرده — یا برعکس، به دلیل طراحی اشتباه، پیچیدگی زیادی اضافه کرده — آن تجربه را برای ما بگویید. این نوع داستانها، برای کسی که امروز در حال طراحی تایپهای یک کتابخانه یا پروژهی جدید است، ارزش عملی بیشتری از هر مستند رسمی دارند.