ژِنریک‌ها در تایپ اسکریپت: کدنویسی انعطاف‌پذیر و قابل استفاده مجدد

فهرست مطالب

ژِنریک‌ها در تایپ‌اسکریپت: کدنویسی انعطاف‌پذیر و قابل استفاده مجدد

در دنیای توسعه نرم‌افزار مدرن، کارایی، ایمنی و قابلیت استفاده مجدد کد از اهمیت بالایی برخوردار است. تایپ‌اسکریپت (TypeScript)، به عنوان یک ابرمجموعه از جاوااسکریپت، با ارائه سیستم نوعی قدرتمند، به توسعه‌دهندگان کمک می‌کند تا کدی پایدارتر و قابل نگهداری‌تر بنویسند. اما یکی از چالش‌های اساسی در هر زبان برنامه‌نویسی با تایپ استاتیک، نوشتن توابع، کلاس‌ها و اینترفیس‌هایی است که بتوانند با انواع مختلفی از داده‌ها کار کنند، بدون اینکه ایمنی نوعی (Type Safety) خود را از دست بدهند.

اینجاست که مفهوم “ژِنریک‌ها” (Generics) وارد میدان می‌شود. ژِنریک‌ها ابزاری قدرتمند در تایپ‌اسکریپت هستند که به شما امکان می‌دهند کامپوننت‌هایی ایجاد کنید که نه تنها قابل استفاده مجدد هستند، بلکه از نظر نوع نیز ایمن باقی می‌مانند. آن‌ها به شما این قابلیت را می‌دهند که هنگام تعریف یک تابع، کلاس یا اینترفیس، آن را طوری بنویسید که بتواند با انواع داده‌ای کار کند که در زمان تعریف ناشناخته‌اند، اما در زمان استفاده از آن مشخص می‌شوند.

هدف این مقاله، کاوش عمیق در ژِنریک‌ها در تایپ‌اسکریپت است؛ از مفاهیم بنیادی گرفته تا الگوهای پیشرفته و بهترین روش‌ها. ما خواهیم دید که چگونه ژِنریک‌ها به ما کمک می‌کنند تا از مشکلاتی مانند استفاده بی‌رویه از `any` (که ایمنی نوعی را از بین می‌برد) جلوگیری کنیم و کدی بنویسیم که هم قدرتمند باشد و هم نگهداری آن آسان‌تر.

پس با ما همراه باشید تا دریچه‌ای جدید به سوی کدنویسی انعطاف‌پذیر و قابل استفاده مجدد در تایپ‌اسکریپت بگشاییم.

مقدمه: چرا ژِنریک‌ها در تایپ‌اسکریپت ضروری هستند؟

برای درک اهمیت ژِنریک‌ها، ابتدا باید به مشکلی بپردازیم که آن‌ها حل می‌کنند. تصور کنید می‌خواهید تابعی بنویسید که یک آرایه را دریافت کرده و اولین عنصر آن را برگرداند. بدون ژِنریک‌ها، ممکن است دو راهکار به ذهنتان برسد:

راهکار ۱: استفاده از نوع خاص

function getFirstNumber(arr: number[]): number {
    return arr[0];
}

function getFirstString(arr: string[]): string {
    return arr[0];
}

// مشکل: نیاز به نوشتن توابع تکراری برای هر نوع

این رویکرد منجر به کپی‌برداری کد (code duplication) می‌شود. اگر بخواهیم همین تابع را برای انواع مختلفی مانند `boolean[]`, `object[]` و غیره داشته باشیم، مجبوریم کد زیادی را تکرار کنیم که این از اصول DRY (Don’t Repeat Yourself) فاصله دارد.

راهکار ۲: استفاده از `any`

function getFirstAny(arr: any[]): any {
    return arr[0];
}

let num = getFirstAny([1, 2, 3]); // num از نوع any است
let str = getFirstAny(["a", "b", "c"]); // str از نوع any است

num.toFixed(2); // هیچ خطایی در زمان کامپایل نمی‌دهد، اما اگر num واقعاً یک رشته باشد، در زمان اجرا خطا می‌دهد.

استفاده از `any` انعطاف‌پذیری ظاهری را به ارمغان می‌آورد، اما ایمنی نوعی را به طور کامل از بین می‌برد. تایپ‌اسکریپت دیگر نمی‌تواند نوع داده‌های بازگشتی را ردیابی کند و شما هرگونه مزیت بررسی نوعی را از دست می‌دهید. این می‌تواند منجر به خطاهای زمان اجرا (runtime errors) شود که یافتن و رفع آن‌ها دشوار است.

راهکار ۳: استفاده از ژِنریک‌ها

function getFirst<T>(arr: T[]): T {
    return arr[0];
}

let num = getFirst([1, 2, 3]); // num از نوع number است
let str = getFirst(["a", "b", "c"]); // str از نوع string است
let bool = getFirst([true, false]); // bool از نوع boolean است

// num.toFixed(2); // کامپایلر می‌داند num یک عدد است و این متد را دارد.
// str.toUpperCase(); // کامپایلر می‌داند str یک رشته است و این متد را دارد.

// num.toUpperCase(); // خطای کامپایل: Property 'toUpperCase' does not exist on type 'number'.

ژِنریک‌ها به ما اجازه می‌دهند یک تابع، کلاس یا اینترفیس بنویسیم که با “انواع عمومی” (generic types) کار می‌کند. این انواع عمومی در زمان تعریف کامپوننت مشخص نیستند، بلکه در زمان استفاده از آن (در هر بار فراخوانی تابع یا ساخت نمونه از کلاس) مشخص می‌شوند. متغیر نوعی `` در اینجا به معنای “هر نوعی” است که در زمان استفاده تعیین می‌شود و تایپ‌اسکریپت آن را به خاطر می‌سپارد.

مزایای اصلی ژِنریک‌ها عبارتند از:

  • ایمنی نوعی (Type Safety): ژِنریک‌ها به شما امکان می‌دهند در حین حفظ انعطاف‌پذیری، از بررسی نوع قوی تایپ‌اسکریپت بهره‌مند شوید.
  • قابلیت استفاده مجدد (Reusability): می‌توانید یک بار کد بنویسید و از آن برای انواع مختلفی از داده‌ها استفاده کنید، بدون نیاز به کپی‌برداری.
  • بهره‌وری توسعه‌دهنده (Developer Productivity): کد تمیزتر، کمتر و با خطاهای کمتری به معنی توسعه سریع‌تر است.
  • خوانایی و نگهداری (Readability & Maintainability): کدی که با ژِنریک‌ها نوشته شده، قصد خود را بهتر بیان می‌کند و تغییرات آینده را آسان‌تر می‌کند.

در ادامه، به بررسی عمیق‌تر نحوه استفاده از ژِنریک‌ها در توابع، اینترفیس‌ها و کلاس‌ها خواهیم پرداخت و سپس به مفاهیم پیشرفته‌تر مانند محدودیت‌های ژِنریک و انواع ابزاری خواهیم رسید.

درک مفاهیم بنیادی ژِنریک‌ها: متغیرهای نوعی (Type Variables)

قلب ژِنریک‌ها، “متغیرهای نوعی” هستند. اینها به شما اجازه می‌دهند تا یک نوع را به عنوان پارامتر به یک تابع، اینترفیس یا کلاس پاس دهید، درست همانند اینکه یک متغیر را به یک تابع پاس می‌دهید.

سینتکس پایه: ``

رایج‌ترین متغیر نوعی مورد استفاده، `T` است (مخفف Type). اما می‌توانید از هر حرف یا نامی که منطقی به نظر می‌رسد، استفاده کنید. متغیرهای نوعی بین علامت‌های “کوچکتر از” و “بزرگتر از” (`<>`) قرار می‌گیرند.

مثال ۱: تابع `identity`

تابع `identity` یک مقدار را می‌گیرد و همان مقدار را برمی‌گرداند. این یک مثال کلاسی برای نمایش ژِنریک‌ها است:

function identity<T>(arg: T): T {
    return arg;
}

// استفاده از تابع identity
let output1 = identity<string>("myString"); // Type of output1 is string
console.log(output1);

let output2 = identity<number>(100);    // Type of output2 is number
console.log(output2);

let output3 = identity<boolean>(true);  // Type of output3 is boolean
console.log(output3);

let output4 = identity<{ name: string }>({ name: "Alice" }); // Type of output4 is { name: string }
console.log(output4);

در این مثال:

  • `<T>` بعد از نام تابع، اعلام می‌کند که `identity` یک تابع ژِنریک است و `T` یک متغیر نوعی است که در داخل تابع استفاده خواهد شد.
  • `arg: T` مشخص می‌کند که پارامتر `arg` از نوع `T` است.
  • `: T` بعد از لیست پارامترها، مشخص می‌کند که نوع بازگشتی تابع نیز از نوع `T` است.

هنگامی که شما `identity<string>(“myString”)` را فراخوانی می‌کنید، TypeScript `T` را با `string` جایگزین می‌کند. به همین ترتیب برای `number` و `boolean`.

استنتاج نوع (Type Inference)

یکی از ویژگی‌های قدرتمند TypeScript، قابلیت استنتاج نوع است. در بسیاری از موارد، نیازی نیست به صراحت نوع متغیر ژِنریک را مشخص کنید، زیرا TypeScript می‌تواند آن را از مقدار ورودی استنتاج کند:

let output5 = identity("myInferredString"); // TypeScript infers T as string
console.log(output5);

let output6 = identity(200); // TypeScript infers T as number
console.log(output6);

کامپایلر TypeScript به اندازه کافی هوشمند است که از نوع آرگومان `arg`، نوع `T` را استنتاج کند. این باعث می‌شود کد شما کوتاه‌تر و خواناتر شود. با این حال، در برخی سناریوهای پیچیده‌تر، ممکن است لازم باشد نوع را به صورت صریح مشخص کنید تا ابهام برطرف شود.

استفاده از چندین متغیر نوعی

شما می‌توانید بیش از یک متغیر نوعی را در یک تعریف ژِنریک استفاده کنید. این زمانی مفید است که شما با چندین نوع مختلف سروکار دارید که به یکدیگر وابسته هستند. به عنوان مثال، در یک تابع که دو آرگومان از انواع مختلف می‌گیرد:

function merge<T, U>(obj1: T, obj2: U): T & U {
    return { ...obj1, ...obj2 } as T & U;
}

let mergedObject = merge({ name: "John" }, { age: 30 });
// mergedObject از نوع { name: string } & { age: number } است، یعنی { name: string, age: number }

console.log(mergedObject.name); // John
console.log(mergedObject.age);  // 30

در اینجا، `T` نوع `obj1` و `U` نوع `obj2` را نشان می‌دهد. نوع بازگشتی `T & U` یک نوع تقاطعی (intersection type) است که شامل تمام ویژگی‌های `T` و `U` می‌شود. این یک مثال قدرتمند از چگونگی استفاده از ژِنریک‌ها برای ایجاد توابع انعطاف‌پذیر است که می‌توانند ساختارهای داده پیچیده را مدیریت کنند، در حالی که ایمنی نوعی کامل را حفظ می‌کنند.

در بخش بعدی، به طور مفصل به کاربرد ژِنریک‌ها در توابع می‌پردازیم و مثال‌های عملی بیشتری را بررسی خواهیم کرد.

ژِنریک‌ها در توابع: ساختاردهی به عملیات‌های عمومی

توابع ژِنریک، متداول‌ترین کاربرد ژِنریک‌ها در تایپ‌اسکریپت هستند. آن‌ها به شما اجازه می‌دهند تا توابعی بنویسید که برای انواع مختلفی از ورودی‌ها کار کنند و در عین حال، ارتباط نوعی بین ورودی و خروجی را حفظ کنند. این بخش به بررسی دقیق‌تر نحوه تعریف و استفاده از توابع ژِنریک می‌پردازد.

تعریف یک تابع ژِنریک

همانطور که قبلاً دیدیم، تعریف یک تابع ژِنریک شامل قرار دادن متغیرهای نوعی در بین علامت‌های `<>` بلافاصله بعد از نام تابع است. این متغیرها سپس می‌توانند برای تعریف انواع پارامترها و نوع بازگشتی تابع استفاده شوند.

function logAndReturn<T>(value: T): T {
    console.log(`Logging value: ${value}`);
    return value;
}

let myNumber = logAndReturn(123); // myNumber is number
let myString = logAndReturn("hello"); // myString is string
let myObject = logAndReturn({ id: 1, name: "Test" }); // myObject is { id: number, name: string }

console.log(myNumber);
console.log(myString);
console.log(myObject);

این تابع `logAndReturn` می‌تواند هر نوعی را بپذیرد و همان نوع را برگرداند، در حالی که در هر مرحله از بررسی نوع اطمینان حاصل می‌کند.

مثال عملی: تابع `firstElement`

بیایید مثال `getFirst` را به `firstElement` تغییر نام دهیم تا خواناتر باشد و کمی بیشتر آن را بررسی کنیم:

function firstElement<T>(arr: T[]): T | undefined {
    if (arr.length === 0) {
        return undefined; // یا می‌توانستیم خطا پرتاب کنیم یا null برگردانیم
    }
    return arr[0];
}

let numbers = [1, 2, 3];
let firstNumber = firstElement(numbers); // firstNumber is type number | undefined
console.log(firstNumber);

let strings = ["a", "b", "c"];
let firstString = firstElement(strings); // firstString is type string | undefined
console.log(firstString);

let emptyArray: number[] = [];
let firstOfEmpty = firstElement(emptyArray); // firstOfEmpty is type number | undefined
console.log(firstOfEmpty);

// با استفاده از Type Guard برای اطمینان از وجود مقدار
if (firstNumber !== undefined) {
    console.log(firstNumber.toFixed(2)); // OK, firstNumber is number here
}

توجه داشته باشید که نوع بازگشتی `T | undefined` است. این نشان می‌دهد که تابع ممکن است یک عنصر از نوع `T` برگرداند یا `undefined` (اگر آرایه خالی باشد). این استفاده دقیق از نوع، به ما کمک می‌کند تا کد مقاوم‌تری بنویسیم.

استفاده از چندین متغیر نوعی: تابع `zip`

یکی دیگر از مثال‌های خوب برای توابع با چندین متغیر نوعی، تابع `zip` است که دو آرایه را ترکیب می‌کند:

function zip<T, U>(arr1: T[], arr2: U[]): Array<[T, U]> {
    const minLength = Math.min(arr1.length, arr2.length);
    const result: Array<[T, U]> = [];
    for (let i = 0; i < minLength; i++) {
        result.push([arr1[i], arr2[i]]);
    }
    return result;
}

let names = ["Alice", "Bob", "Charlie"];
let ages = [25, 30, 35];

let zippedData = zip(names, ages);
// zippedData is Array<[string, number]>
// [["Alice", 25], ["Bob", 30], ["Charlie", 35]]
console.log(zippedData);

let colors = ["red", "green"];
let sizes = [10, 20, 30];

let zippedShortData = zip(colors, sizes);
// zippedShortData is Array<[string, number]>
// [["red", 10], ["green", 20]]
console.log(zippedShortData);

این تابع `zip` دو آرایه از انواع مختلف (که با `T` و `U` نشان داده شده‌اند) را می‌گیرد و یک آرایه از تاپل‌ها (tuple) برمی‌گرداند که هر تاپل شامل یک عنصر از `T` و یک عنصر از `U` است.

استفاده از ژِنریک‌ها با توابع فلش (Arrow Functions)

ژِنریک‌ها را می‌توان با توابع فلش نیز استفاده کرد:

const logAndReturnArrow = <T>(value: T): T => {
    console.log(`Arrow logging value: ${value}`);
    return value;
};

let arrowResult = logAndReturnArrow("TypeScript"); // arrowResult is string
console.log(arrowResult);

سینتکس برای توابع فلش کمی متفاوت است. متغیر نوعی `<T>` قبل از پارامترهای تابع قرار می‌گیرد.

نکته مهم: محدودیت‌های ژِنریک (Generic Constraints)

گاهی اوقات شما نیاز دارید که توابع ژِنریک شما با نوع خاصی از `T` کار کنند. مثلاً، اگر بخواهید متدی را روی `T` فراخوانی کنید که مطمئنید روی آن وجود دارد، مانند متد `length` روی یک رشته یا آرایه. در این موارد، باید از محدودیت‌های ژِنریک استفاده کنید، که در بخش بعدی به آن خواهیم پرداخت. اما برای درک اولیه، اگر بخواهید طول یک مقدار را محاسبه کنید:

// این خطا می‌دهد زیرا T می‌تواند هر چیزی باشد و لزوماً پراپرتی length را ندارد.
// function getLength<T>(arg: T): number {
//     return arg.length;
// }

// برای حل این مشکل، نیاز به constraint داریم.

ژِنریک‌ها در توابع، ابزاری قدرتمند برای افزایش انعطاف‌پذیری و ایمنی نوعی در کد شما هستند. آن‌ها به شما امکان می‌دهند عملیات‌های عمومی را به گونه‌ای تعریف کنید که با انواع مختلف داده‌ها به طور هوشمندانه کار کنند.

ژِنریک‌ها در اینترفیس‌ها و نوع‌های مستعار: تعریف ساختارهای داده انعطاف‌پذیر

فراتر از توابع، ژِنریک‌ها در تعریف اینترفیس‌ها (Interfaces) و نوع‌های مستعار (Type Aliases) نیز نقش حیاتی دارند. این امکان را به شما می‌دهند که ساختارهای داده‌ای را ایجاد کنید که نوع محتوای آن‌ها در زمان تعریف، ژِنریک است و در زمان استفاده، با انواع خاصی پر می‌شود.

ژِنریک‌ها در اینترفیس‌ها

اینترفیس‌های ژِنریک به شما اجازه می‌دهند تا قراردادهایی (contracts) را تعریف کنید که می‌توانند با انواع داده‌ای مختلف سازگار باشند. این برای ساختارهای داده‌ای مانند لیست‌ها، درختان، صف‌ها، یا پاسخ‌های API که محتوایشان می‌تواند از نوع‌های متفاوتی باشد، بسیار مفید است.

مثال ۱: اینترفیس `GenericList`

interface GenericList<T> {
    items: T[];
    add(item: T): void;
    get(index: number): T | undefined;
    size(): number;
}

// پیاده‌سازی یک لیست از اعداد
class NumberList implements GenericList<number> {
    items: number[] = [];

    add(item: number): void {
        this.items.push(item);
    }

    get(index: number): number | undefined {
        return this.items[index];
    }

    size(): number {
        return this.items.length;
    }
}

const myNumbers = new NumberList();
myNumbers.add(10);
myNumbers.add(20);
console.log(myNumbers.get(0)); // 10
// myNumbers.add("hello"); // خطای کامپایل: Argument of type 'string' is not assignable to parameter of type 'number'.

// پیاده‌سازی یک لیست از رشته‌ها
class StringList implements GenericList<string> {
    items: string[] = [];

    add(item: string): void {
        this.items.push(item);
    }

    get(index: number): string | undefined {
        return this.items[index];
    }

    size(): number {
        return this.items.length;
    }
}

const myStrings = new StringList();
myStrings.add("apple");
myStrings.add("banana");
console.log(myStrings.get(1)); // banana

در این مثال، `GenericList<T>` یک اینترفیس ژِنریک است که `T` نوع عناصری است که لیست نگهداری می‌کند. این به ما امکان می‌دهد یک اینترفیس کلی داشته باشیم که می‌تواند توسط کلاس‌هایی با انواع مختلف `T` پیاده‌سازی شود.

مثال ۲: اینترفیس `ApiResponse`

یک سناریوی رایج دیگر، تعریف ساختار پاسخ‌های API است که داده‌های آن‌ها می‌توانند از انواع مختلفی باشند:

interface ApiResponse<T> {
    status: number;
    message: string;
    data: T; // 'data' can be of any type, defined by T
    timestamp: Date;
}

interface User {
    id: number;
    name: string;
    email: string;
}

interface Product {
    productId: string;
    productName: string;
    price: number;
}

const userResponse: ApiResponse<User> = {
    status: 200,
    message: "User fetched successfully",
    data: { id: 1, name: "Alice", email: "alice@example.com" },
    timestamp: new Date()
};

console.log(userResponse.data.name); // Alice

const productResponse: ApiResponse<Product[]> = {
    status: 200,
    message: "Products fetched successfully",
    data: [
        { productId: "P001", productName: "Laptop", price: 1200 },
        { productId: "P002", productName: "Mouse", price: 25 }
    ],
    timestamp: new Date()
};

console.log(productResponse.data[0].productName); // Laptop

// اگر داده‌ای وجود نداشته باشد یا خطایی رخ دهد:
const errorResponse: ApiResponse<null> = {
    status: 404,
    message: "Resource not found",
    data: null,
    timestamp: new Date()
};

console.log(errorResponse.data); // null

اینترفیس `ApiResponse<T>` با استفاده از `T` برای مشخص کردن نوع `data`، بسیار انعطاف‌پذیر می‌شود. این به ما اجازه می‌دهد تا یک ساختار پاسخ ثابت داشته باشیم، اما نوع داده‌های اصلی را بر اساس نیاز تغییر دهیم.

ژِنریک‌ها در نوع‌های مستعار (Type Aliases)

نوع‌های مستعار ژِنریک به شما راهی مشابه اینترفیس‌ها برای تعریف نوع‌های قابل استفاده مجدد با پارامترهای نوعی ارائه می‌دهند. در بسیاری از موارد، می‌توانید از هر دو استفاده کنید، اما تفاوت‌های ظریفی وجود دارد (مثلاً اینترفیس‌ها قابلیت ادغام (declaration merging) را دارند، در حالی که نوع‌های مستعار ندارند).

مثال ۱: نوع مستعار `Result`

نوع `Result` از Rust یا زبان‌های تابعی دیگر الهام گرفته شده و برای مدیریت عملیات‌هایی که ممکن است موفق یا ناموفق باشند، استفاده می‌شود:

type Result<T, E> = { success: true; value: T } | { success: false; error: E };

// یک تابع که ممکن است موفق یا ناموفق باشد
function safeDivide(numerator: number, denominator: number): Result<number, string> {
    if (denominator === 0) {
        return { success: false, error: "Cannot divide by zero" };
    }
    return { success: true, value: numerator / denominator };
}

const divisionResult1 = safeDivide(10, 2);
if (divisionResult1.success) {
    console.log(`Division successful: ${divisionResult1.value}`); // Division successful: 5
} else {
    console.log(`Division failed: ${divisionResult1.error}`);
}

const divisionResult2 = safeDivide(10, 0);
if (divisionResult2.success) {
    console.log(`Division successful: ${divisionResult2.value}`);
} else {
    console.log(`Division failed: ${divisionResult2.error}`); // Division failed: Cannot divide by zero
}

در اینجا، `Result<T, E>` یک نوع مستعار ژِنریک است که دو پارامتر نوعی (`T` برای مقدار موفقیت و `E` برای خطا) می‌پذیرد. این رویکرد به شما کمک می‌کند تا خطاهای قابل پیش‌بینی را به صورت نوعی ایمن و صریح مدیریت کنید.

مثال ۲: نوع مستعار `Pair`

type Pair<F, S> = [F, S]; // Defines a tuple of two elements of generic types

const nameAge: Pair<string, number> = ["Bob", 42];
console.log(nameAge[0]); // Bob
console.log(nameAge[1]); // 42

const userStatus: Pair<User, boolean> = [{ id: 2, name: "Charlie", email: "charlie@example.com" }, true];
console.log(userStatus[0].name); // Charlie

نوع مستعار `Pair<F, S>` یک تاپل ژِنریک را تعریف می‌کند که می‌تواند دو مقدار از هر نوعی را نگه دارد. این نوع مستعار بسیار مفید است زمانی که شما نیاز به گروه‌بندی دو مقدار مرتبط دارید، بدون اینکه یک اینترفیس کامل برای آن بسازید.

استفاده از ژِنریک‌ها در اینترفیس‌ها و نوع‌های مستعار به شما امکان می‌دهد تا ساختارهای داده‌ای را بسازید که به صورت نوعی ایمن، قابل استفاده مجدد و بسیار انعطاف‌پذیر هستند. این‌ها بلوک‌های سازنده اصلی برای طراحی سیستم‌های بزرگ و پیچیده در تایپ‌اسکریپت هستند.

ژِنریک‌ها در کلاس‌ها: ایجاد کامپوننت‌های قابل استفاده مجدد و ایمن از نظر نوع

ژِنریک‌ها نقش اساسی در طراحی کلاس‌هایی دارند که باید با انواع داده‌های مختلف کار کنند، در حالی که تضمین ایمنی نوعی را نیز فراهم می‌کنند. کلاس‌های ژِنریک به شما امکان می‌دهند تا ساختارهای داده‌ای مانند صف‌ها (queues)، پشته‌ها (stacks)، درختان، یا مخازن داده‌ای (repositories) را به گونه‌ای پیاده‌سازی کنید که بتوانند عناصر هر نوعی را در خود نگه دارند.

تعریف یک کلاس ژِنریک

برای تعریف یک کلاس ژِنریک، متغیرهای نوعی را بعد از نام کلاس قرار می‌دهید. این متغیرها سپس می‌توانند در سراسر کلاس برای تعیین انواع پراپرتی‌ها، پارامترهای متدها و نوع بازگشتی متدها استفاده شوند.

مثال ۱: کلاس `Queue`

یک صف (Queue) یک ساختار داده‌ای است که در آن عناصر به ترتیب FIFO (First-In, First-Out) پردازش می‌شوند. می‌توانیم یک کلاس `Queue` ژِنریک بسازیم که بتواند هر نوع عنصری را در خود نگه دارد:

class Queue<T> {
    private data: T[] = [];

    enqueue(item: T): void {
        this.data.push(item);
    }

    dequeue(): T | undefined {
        return this.data.shift(); // Removes the first element and returns it
    }

    peek(): T | undefined {
        return this.data.length > 0 ? this.data[0] : undefined;
    }

    size(): number {
        return this.data.length;
    }

    isEmpty(): boolean {
        return this.data.length === 0;
    }
}

// استفاده از Queue برای اعداد
const numberQueue = new Queue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);
console.log(numberQueue.dequeue()); // 10 (type: number)
console.log(numberQueue.peek());    // 20 (type: number)
// numberQueue.enqueue("hello"); // خطای کامپایل: Argument of type 'string' is not assignable to parameter of type 'number'.

// استفاده از Queue برای رشته‌ها
const stringQueue = new Queue<string>();
stringQueue.enqueue("first");
stringQueue.enqueue("second");
console.log(stringQueue.dequeue()); // "first" (type: string)
console.log(stringQueue.size());    // 1

در این مثال، `Queue<T>` یک کلاس ژِنریک است که `T` نوع عناصر را مشخص می‌کند. این کلاس تضمین می‌کند که فقط عناصر از نوع `T` می‌توانند به صف اضافه شوند و عناصری که از صف خارج می‌شوند نیز از نوع `T` خواهند بود.

مثال ۲: کلاس `Repository`

در معماری نرم‌افزار، الگوی Repository برای انتزاع لایه دسترسی به داده‌ها استفاده می‌شود. می‌توانیم یک Repository ژِنریک بسازیم که برای هر نوع موجودیتی کار کند:

interface Identifiable {
    id: string | number;
}

class InMemoryRepository<T extends Identifiable> {
    private items: Map<T['id'], T> = new Map();

    add(item: T): void {
        if (this.items.has(item.id)) {
            console.warn(`Item with id ${item.id} already exists. Overwriting.`);
        }
        this.items.set(item.id, item);
    }

    getById(id: T['id']): T | undefined {
        return this.items.get(id);
    }

    getAll(): T[] {
        return Array.from(this.items.values());
    }

    deleteById(id: T['id']): boolean {
        return this.items.delete(id);
    }
}

interface User extends Identifiable {
    id: number;
    name: string;
    email: string;
}

interface Product extends Identifiable {
    id: string;
    productName: string;
    price: number;
}

const userRepository = new InMemoryRepository<User>();
userRepository.add({ id: 1, name: "Alice", email: "alice@example.com" });
userRepository.add({ id: 2, name: "Bob", email: "bob@example.com" });

const user1 = userRepository.getById(1);
if (user1) {
    console.log(`Found user: ${user1.name}`); // Found user: Alice
}

const productRepository = new InMemoryRepository<Product>();
productRepository.add({ id: "P001", productName: "Laptop", price: 1200 });
productRepository.add({ id: "P002", productName: "Mouse", price: 25 });

const laptop = productRepository.getById("P001");
if (laptop) {
    console.log(`Found product: ${laptop.productName}`); // Found product: Laptop
}

// userRepository.add({ name: "Invalid" }); // خطای کامپایل: Property 'id' is missing

در این مثال، `InMemoryRepository<T extends Identifiable>` از یک “محدودیت ژِنریک” (Generic Constraint) استفاده می‌کند. `T extends Identifiable` به TypeScript می‌گوید که هر نوع `T` که به این کلاس پاس داده می‌شود، باید دارای پراپرتی `id` باشد (از طریق اینترفیس `Identifiable`). این تضمین می‌کند که متدهایی مانند `add` و `getById` همیشه می‌توانند به `item.id` دسترسی داشته باشند.

محدودیت‌ها و ملاحظات

  • متدهای استاتیک: متدهای استاتیک در کلاس‌های ژِنریک نمی‌توانند به متغیرهای نوعی کلاس دسترسی داشته باشند. اگر متد استاتیکی نیاز به ژِنریک بودن دارد، باید متغیر نوعی خود را تعریف کند.
    class GenericBox<T> {
        value: T;
        constructor(value: T) {
            this.value = value;
        }
    
        static createBox<U>(item: U): GenericBox<U> { // U is independent from T
            return new GenericBox(item);
        }
    }
    
    const box1 = GenericBox.createBox("hello"); // box1 is GenericBox<string>
    
  • استنتاج نوع در سازنده: تایپ‌اسکریپت می‌تواند نوع ژِنریک را از آرگومان‌های سازنده استنتاج کند:
    const numBox = new GenericBox(123); // numBox is GenericBox<number>
    const strBox = new GenericBox("world"); // strBox is GenericBox<string>
    

کلاس‌های ژِنریک بخش جدایی‌ناپذیری از نوشتن کدی هستند که هم قدرتمند باشد و هم نگهداری آن آسان. آن‌ها به شما اجازه می‌دهند تا مؤلفه‌های قابل استفاده مجددی بسازید که به طور ایمن با انواع مختلف داده‌ها تعامل دارند و انعطاف‌پذیری زیادی را به معماری نرم‌افزار شما اضافه می‌کنند.

محدودیت‌های ژِنریک (Generic Constraints): مشخص کردن مرزهای نوعی

تا اینجا دیدیم که ژِنریک‌ها به ما امکان می‌دهند با هر نوعی کار کنیم. اما گاهی اوقات، شما نیاز دارید که یک تابع یا کلاس ژِنریک فقط با زیرمجموعه‌ای از انواع کار کند. برای مثال، ممکن است بخواهید یک متد خاص را روی متغیر نوعی فراخوانی کنید، اما این متد فقط روی انواع خاصی وجود دارد (مانند متد `length` روی رشته‌ها و آرایه‌ها). در چنین مواردی، باید از “محدودیت‌های ژِنریک” (Generic Constraints) استفاده کنید.

محدودیت‌های ژِنریک به شما اجازه می‌دهند که مشخص کنید متغیر نوعی شما باید حداقل چه پراپرتی‌ها یا متدهایی را داشته باشد. این کار با استفاده از کلمه کلیدی `extends` انجام می‌شود.

سینتکس `extends`

سینتکس پایه برای محدودیت ژِنریک به شکل زیر است:

function someGenericFunction<T extends SomeType>(arg: T): T {
    // ...
}

این بدان معناست که `T` باید نوعی باشد که “قابل انتساب به” (assignable to) `SomeType` باشد، یا به عبارت دیگر، `T` باید زیرنوعی (subtype) از `SomeType` باشد. `SomeType` می‌تواند یک اینترفیس، یک کلاس، یک نوع اولیه (primitive type) یا یک نوع ترکیبی (union type) باشد.

مثال ۱: محدود کردن به انواع با پراپرتی `length`

همانطور که قبلاً اشاره شد، اگر بخواهیم طول یک مقدار را محاسبه کنیم، نیاز به تضمین داریم که آن مقدار دارای پراپرتی `length` باشد:

interface HasLength {
    length: number;
}

function getLength<T extends HasLength>(arg: T): number {
    return arg.length;
}

console.log(getLength("hello"));      // 5 (string has length)
console.log(getLength([1, 2, 3]));    // 3 (array has length)
console.log(getLength({ length: 10 })); // 10 (object with length property)

// console.log(getLength(123)); // خطای کامپایل: Argument of type 'number' is not assignable to parameter of type 'HasLength'.
// console.log(getLength(true)); // خطای کامپایل: Argument of type 'boolean' is not assignable to parameter of type 'HasLength'.

در اینجا، `T extends HasLength` به کامپایلر می‌گوید که `T` باید یک نوعی باشد که حداقل پراپرتی `length` از نوع `number` را داشته باشد. این تضمین می‌کند که `arg.length` همیشه یک عملیات معتبر خواهد بود.

مثال ۲: محدود کردن به آبجکت‌ها (`T extends object`)

اگر تابعی دارید که نیاز دارد با آبجکت‌ها کار کند و مثلاً آن‌ها را ترکیب کند، می‌توانید آن را به آبجکت‌ها محدود کنید:

function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
    return { ...obj1, ...obj2 } as T & U;
}

const person = { name: "Alice", age: 30 };
const address = { city: "New York", zip: "10001" };

const merged = mergeObjects(person, address);
console.log(merged.name);    // Alice
console.log(merged.city);    // New York

// mergeObjects(1, 2); // خطای کامپایل: Argument of type 'number' is not assignable to parameter of type 'object'.

`T extends object` تضمین می‌کند که `T` و `U` هر دو آبجکت هستند و بنابراین می‌توان از عملگر spread (`…`) روی آن‌ها استفاده کرد.

مثال ۳: محدودیت `keyof` (`T extends keyof U`)

این یکی از قدرتمندترین محدودیت‌ها است که به شما امکان می‌دهد مطمئن شوید یک متغیر نوعی، یک کلید معتبر از آبجکت دیگری است. این بسیار مفید است برای توابع کمکی که روی پراپرتی‌های آبجکت کار می‌کنند.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = {
    firstName: "John",
    lastName: "Doe",
    age: 30
};

let userFirstName = getProperty(user, "firstName"); // userFirstName is string
console.log(userFirstName);

let userAge = getProperty(user, "age"); // userAge is number
console.log(userAge);

// let invalidKey = getProperty(user, "job"); // خطای کامپایل: Argument of type '"job"' is not assignable to parameter of type '"firstName" | "lastName" | "age"'.

در این تابع `getProperty`:

  • `T` نوع آبجکتی است که به تابع داده می‌شود (مثلاً `{ firstName: string, lastName: string, age: number }`).
  • `K extends keyof T` تضمین می‌کند که `K` فقط می‌تواند یکی از کلیدهای نوع `T` باشد (یعنی `”firstName” | “lastName” | “age”`).
  • `T[K]` به TypeScript می‌گوید که نوع بازگشتی، نوع پراپرتی `K` از آبجکت `T` است (مثلاً اگر `K` برابر `”firstName”` باشد، نوع بازگشتی `string` خواهد بود).

این الگو به طور گسترده‌ای در فریم‌ورک‌ها و کتابخانه‌ها برای ساخت توابع کمکی نوع-امن استفاده می‌شود.

مثال ۴: محدودیت برای سازنده‌ها (`new () => T`)

اگر نیاز دارید که یک تابع ژِنریک بتواند یک نمونه جدید از نوع `T` ایجاد کند، `T` باید یک تابع سازنده (constructor function) باشد:

class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet(): string {
        return "Hello, " + this.greeting;
    }
}

function createAndGreet<T extends Greeter & new (...args: any[]) => T>(
    ctor: T,
    message: string
): T {
    return new ctor(message);
}

// استفاده از تابع
let myGreeter = createAndGreet(Greeter, "World");
console.log(myGreeter.greet()); // Hello, World

// اگر Greeter شامل Greeter نباشد
// class OtherClass { }
// createAndGreet(OtherClass, "Test"); // خطای کامپایل: Type 'OtherClass' does not satisfy the constraint 'Greeter & Newable<Greeter>'.

عبارت `T extends Greeter & new (…args: any[]) => T` به این معنی است که `T` باید یک نوع باشد که هم زیرنوع `Greeter` است و هم یک تابع سازنده (با هر تعداد آرگومان) است که نمونه‌ای از `T` را برمی‌گرداند. این به ما امکان می‌دهد یک کلاس را به صورت ژِنریک پاس دهیم و نمونه‌ای از آن را ایجاد کنیم.

محدودیت‌های ژِنریک ابزاری حیاتی برای نوشتن کدی هستند که هم انعطاف‌پذیر باشد و هم از نظر نوع ایمن. آن‌ها به شما اجازه می‌دهند تا دامنه انواع قابل قبول برای متغیرهای ژِنریک را محدود کنید و اطمینان حاصل کنید که عملیات‌های خاصی همیشه معتبر خواهند بود.

انواع ابزاری (Utility Types) و ژِنریک‌های پیشرفته: گسترش قابلیت‌های تایپ‌اسکریپت

تایپ‌اسکریپت مجموعه‌ای غنی از “انواع ابزاری” (Utility Types) داخلی را ارائه می‌دهد که بسیاری از آن‌ها بر پایه ژِنریک‌ها ساخته شده‌اند. این انواع ابزاری به شما امکان می‌دهند تا نوع‌های جدیدی را بر اساس نوع‌های موجود بسازید و عملیات‌های پیچیده‌ای را روی آن‌ها انجام دهید، بدون اینکه نیاز به تعریف مجدد ساختار کامل داشته باشید. در کنار این‌ها، مفاهیم پیشرفته‌ای مانند انواع شرطی (Conditional Types) نیز قدرت ژِنریک‌ها را چندین برابر می‌کنند.

انواع ابزاری رایج (Common Utility Types)

بیایید نگاهی به برخی از پرکاربردترین انواع ابزاری بیندازیم و ببینیم چگونه از ژِنریک‌ها برای انجام وظایف خود استفاده می‌کنند:

۱. **`Partial`:**

این نوع، تمام پراپرتی‌های `T` را اختیاری (optional) می‌کند. این برای زمانی مفید است که می‌خواهید یک آبجکت را به صورت جزئی به روزرسانی کنید.

interface Todo {
    title: string;
    description: string;
    completed: boolean;
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>): Todo {
    return { ...todo, ...fieldsToUpdate };
}

const todo1: Todo = {
    title: "Organize desk",
    description: "clear clutter",
    completed: false
};

const updatedTodo = updateTodo(todo1, {
    description: "throw out trash",
    completed: true
});

console.log(updatedTodo);
/*
{
  title: 'Organize desk',
  description: 'throw out trash',
  completed: true
}
*/

۲. **`Required`:**

برعکس `Partial`، این نوع تمام پراپرتی‌های `T` را اجباری (required) می‌کند. مفید برای تضمین پر بودن یک آبجکت.

interface UserProfile {
    name?: string;
    email?: string;
    age?: number;
}

type FullUserProfile = Required<UserProfile>;

const user: FullUserProfile = {
    name: "John Doe",
    email: "john@example.com",
    age: 30
};

// const incompleteUser: FullUserProfile = { name: "Jane" }; // خطای کامپایل: Property 'email' is missing

۳. **`Readonly`:**

تمام پراپرتی‌های `T` را فقط-خواندنی (readonly) می‌کند، به این معنی که نمی‌توان آن‌ها را تغییر داد.

interface Point {
    x: number;
    y: number;
}

type ReadonlyPoint = Readonly<Point>;

const p: ReadonlyPoint = { x: 10, y: 20 };
// p.x = 5; // خطای کامپایل: Cannot assign to 'x' because it is a read-only property.

۴. **`Pick`:**

یک نوع جدید با انتخاب مجموعه‌ای از پراپرتی‌های `K` از `T` ایجاد می‌کند.

interface Product {
    id: string;
    name: string;
    price: number;
    description: string;
}

type ProductSummary = Pick<Product, "id" | "name">;

const laptopSummary: ProductSummary = {
    id: "P001",
    name: "Dell XPS"
};

// const invalidSummary: ProductSummary = { id: "P002", description: "..." }; // خطا: 'description' is not valid

۵. **`Omit`:**

یک نوع جدید با حذف مجموعه‌ای از پراپرتی‌های `K` از `T` ایجاد می‌کند.

interface Product {
    id: string;
    name: string;
    price: number;
    description: string;
}

type ProductWithoutDescription = Omit<Product, "description">;

const tablet: ProductWithoutDescription = {
    id: "P003",
    name: "iPad",
    price: 799
};

// const invalidTablet: ProductWithoutDescription = { id: "P004", description: "..." }; // خطا: 'description' is not valid

۶. **`Exclude`:**

نوع‌هایی را از `T` که قابل انتساب به `U` هستند، حذف می‌کند.

type AllColors = "red" | "green" | "blue" | "yellow";
type PrimaryColors = "red" | "blue";

type NonPrimaryColors = Exclude<AllColors, PrimaryColors>; // "green" | "yellow"
const myColor: NonPrimaryColors = "green";
// const anotherColor: NonPrimaryColors = "red"; // خطای کامپایل

۷. **`Extract`:**

نوع‌هایی از `T` را که قابل انتساب به `U` هستند، استخراج می‌کند.

type AllShapes = "circle" | "square" | "triangle" | "hexagon";
type RoundedShapes = "circle" | "oval";

type OnlyRoundedShapes = Extract<AllShapes, RoundedShapes>; // "circle"
const myShape: OnlyRoundedShapes = "circle";
// const anotherShape: OnlyRoundedShapes = "square"; // خطای کامپایل

۸. **`NonNullable`:**

مقادیر `null` و `undefined` را از `T` حذف می‌کند.

type NullableString = string | null | undefined;
type ValidString = NonNullable<NullableString>; // string
const text: ValidString = "Hello";
// const empty: ValidString = null; // خطای کامپایل

۹. **`Record`:**

یک نوع آبجکت می‌سازد که پراپرتی‌های آن از نوع `K` و مقادیر آن‌ها از نوع `T` هستند.

type UserRoles = "admin" | "editor" | "viewer";
type RoleDescriptions = Record<UserRoles, string>;

const roles: RoleDescriptions = {
    admin: "Full access to all features",
    editor: "Can create and modify content",
    viewer: "Can only view content"
};

console.log(roles.admin); // Full access to all features
// console.log(roles.guest); // خطای کامپایل: Property 'guest' does not exist

انواع شرطی (Conditional Types) با ژِنریک‌ها

انواع شرطی به شما امکان می‌دهند تا بر اساس یک شرط نوعی، نوع متفاوتی را انتخاب کنید. این‌ها به طور گسترده‌ای در پیاده‌سازی انواع ابزاری داخلی تایپ‌اسکریپت و الگوهای نوعی پیشرفته استفاده می‌شوند. سینتکس اصلی آن‌ها شبیه عملگر سه‌تایی در جاوااسکریپت است:

SomeType extends OtherType ? TrueType : FalseType;

این بدان معناست که “اگر `SomeType` زیرنوعی از `OtherType` باشد، آنگاه نوع `TrueType` را برگردان، در غیر این صورت `FalseType` را برگردان.”

مثال: `ReturnType`

`ReturnType` یک نوع ابزاری داخلی است که نوع بازگشتی یک تابع را استخراج می‌کند. نحوه پیاده‌سازی آن با استفاده از انواع شرطی و کلمه کلیدی `infer` است:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function greet(name: string): string {
    return `Hello, ${name}`;
}

type GreetReturnType = MyReturnType<typeof greet>; // string
console.log(typeof ({} as GreetReturnType)); // "string"

function add(a: number, b: number): number {
    return a + b;
}

type AddReturnType = MyReturnType<typeof add>; // number
console.log(typeof ({} as AddReturnType)); // "number"

type NonFunctionType = MyReturnType<string>; // any
console.log(typeof ({} as NonFunctionType)); // "undefined" (because it's 'any')

در `MyReturnType`:

  • `T extends (…args: any[]) => infer R` بررسی می‌کند که آیا `T` یک تابع است (هر تابعی که هر تعداد آرگومان می‌گیرد و چیزی را برمی‌گرداند).
  • `infer R` به TypeScript می‌گوید که “نوع بازگشتی این تابع را استنتاج کن و آن را در متغیر نوعی جدید `R` ذخیره کن.”
  • اگر `T` یک تابع باشد، نوع `R` (نوع بازگشتی) برگردانده می‌شود. در غیر این صورت، `any` برگردانده می‌شود.

انواع ابزاری و انواع شرطی به شما اجازه می‌دهند تا با ژِنریک‌ها به شکلی بسیار قدرتمند و بیانگر کار کنید. آن‌ها قابلیت‌های تایپ‌اسکریپت را به میزان قابل توجهی گسترش می‌دهند و به شما امکان می‌دهند سیستم‌های نوعی پیچیده و دقیقی را برای برنامه‌های خود بسازید.

بهترین روش‌ها و الگوهای پیشرفته در استفاده از ژِنریک‌ها

با درک عمیق از ژِنریک‌ها، وقت آن است که به بهترین روش‌ها و الگوهای پیشرفته‌ای بپردازیم که به شما کمک می‌کنند تا از این قابلیت قدرتمند تایپ‌اسکریپت به بهترین نحو استفاده کنید و کدی تمیزتر، قابل نگهداری‌تر و ایمن‌تر بنویسید.

۱. نام‌گذاری متغیرهای نوعی

یک قرارداد نام‌گذاری استاندارد به خوانایی کد شما کمک می‌کند. رایج‌ترین قراردادها عبارتند از:

  • `T` (Type): رایج‌ترین و عمومی‌ترین نام، برای متغیر نوعی پیش‌فرض.
  • `K` (Key), `V` (Value): برای جفت‌های کلید-مقدار (مانند در `Map` یا آبجکت‌ها).
  • `U` (Union), `E` (Element): برای متغیرهای نوعی اضافی یا برای عناصر در یک مجموعه.
  • `P` (Props), `S` (State): در محیط‌هایی مانند React برای Props و State کامپوننت‌ها.
function identity<T>(arg: T): T { /* ... */ }
type Dictionary<K, V> = Record<K, V>;
function merge<T, U>(obj1: T, obj2: U): T & U { /* ... */ }

اگر نامی با معنای خاصی در دامنه شما وجود دارد، استفاده از آن (مانند `TUser` یا `TProduct`) می‌تواند وضوح را افزایش دهد، به خصوص در بخش‌های بزرگتر کد.

۲. چه زمانی از ژِنریک‌ها استفاده کنیم و چه زمانی نکنیم؟

ژِنریک‌ها قدرتمندند، اما همیشه بهترین راه حل نیستند. استفاده بیش از حد یا نادرست از آن‌ها می‌تواند منجر به پیچیدگی غیرضروری شود.

  • زمانی که استفاده کنیم:
    • زمانی که می‌خواهید یک تابع، کلاس یا اینترفیس با انواع مختلفی از داده‌ها کار کند.
    • زمانی که نیاز به حفظ رابطه نوعی بین ورودی‌ها و خروجی‌های تابع دارید.
    • زمانی که می‌خواهید از تکرار کد (code duplication) برای عملیات‌های مشابه روی انواع مختلف جلوگیری کنید.
    • زمانی که در حال ساخت کتابخانه‌ها یا فریم‌ورک‌هایی هستید که نیاز به انعطاف‌پذیری بالایی دارند.
  • زمانی که استفاده نکنیم:
    • زمانی که یک نوع مشخص، نیاز شما را برطرف می‌کند. اضافه کردن ژِنریک‌ها به سادگی به دلیل “انعطاف‌پذیری بیشتر” بدون نیاز واقعی، فقط پیچیدگی را افزایش می‌دهد.
    • زمانی که نوع `any` (با دقت و آگاهی کامل از عواقب آن) می‌تواند کار شما را برای یک بخش کوچک یا موقتی راه بیندازد و اضافه کردن ژِنریک‌ها به مراتب پیچیده‌تر است. (هرچند `any` معمولاً آخرین راه حل است).
    • برای پراپرتی‌های خصوصی یا داخلی در کلاس‌ها که نوع آن‌ها هرگز از خارج از کلاس افشا نمی‌شود و همیشه ثابت است.

۳. رفع اشکال (Debugging) انواع ژِنریک

وقتی با ژِنریک‌ها سروکار دارید و خطاهای نوعی دریافت می‌کنید، ممکن است فهمیدن دقیق مشکل دشوار باشد. ابزارهای زیر می‌توانند کمک کننده باشند:

  • Hover over types: در VS Code و اکثر IDEها، با نگه داشتن ماوس روی یک متغیر یا عبارت، می‌توانید نوع استنتاج شده آن را مشاهده کنید. این به شما کمک می‌کند ببینید TypeScript چگونه انواع ژِنریک شما را حل کرده است.
  • Playground تایپ‌اسکریپت: از TypeScript Playground استفاده کنید. این یک محیط عالی برای تست سریع قطعه کدهای ژِنریک و دیدن پیام‌های خطا به صورت زنده است.
  • استفاده از `typeof` و `keyof`: این عملگرها می‌توانند برای استخراج انواع در زمان کامپایل مفید باشند و در ترکیب با ژِنریک‌ها، دید بهتری از ساختار نوعی کد شما ارائه دهند.
  • کوچک کردن مثال: اگر با یک مشکل پیچیده مواجه شدید، سعی کنید یک مثال کوچک و مستقل بسازید که فقط قسمت مربوط به ژِنریک‌ها و خطای نوعی را نشان دهد. این به شما کمک می‌کند تا مشکل را جدا کرده و حل کنید.

۴. الگوهای پیشرفته: Higher-Order Components/Functions

در توسعه رابط کاربری (UI) با فریم‌ورک‌هایی مانند React، ژِنریک‌ها نقش مهمی در Higher-Order Components (HOCs) یا Higher-Order Functions (HOFs) دارند. این الگوها توابع/کامپوننت‌هایی هستند که توابع/کامپوننت‌های دیگری را می‌گیرند و توابع/کامپوننت‌های جدیدی را برمی‌گردانند که منطق اضافی دارند.

// یک Higher-Order Function برای اضافه کردن قابلیت لاگ کردن
function withLogging<T extends Function>(func: T): T {
    return ((...args: any[]) => {
        console.log(`Calling function ${func.name} with args:`, args);
        const result = func(...args);
        console.log(`Function ${func.name} returned:`, result);
        return result;
    }) as T; // Type assertion needed here for simplicity, or refine the return type
}

function add(a: number, b: number) {
    return a + b;
}

const loggedAdd = withLogging(add);
console.log(loggedAdd(5, 3)); // Logs messages and returns 8

// توجه: تایپ کردن HOC ها به طور دقیق در React پیچیده‌تر است و نیازمند درک عمیق از Type Inference و Utility Types است.
// این فقط یک مثال ساده برای مفهوم است.

در این مثال، `withLogging` یک تابع ژِنریک است که هر تابعی `T` را می‌گیرد و یک تابع جدید از همان نوع `T` را برمی‌گرداند که قابلیت لاگ کردن به آن اضافه شده است. این کار با حفظ نوع ورودی/خروجی تابع اصلی انجام می‌شود.

۵. استفاده از ژِنریک‌ها با کلاس‌های Decorator

Decorators یک ویژگی متاپروگرمینگ در تایپ‌اسکریپت هستند که به شما امکان می‌دهند رفتار کلاس‌ها، متدها، پراپرتی‌ها یا پارامترها را در زمان تعریف (نه زمان اجرا) تغییر دهید. ژِنریک‌ها در تایپ‌کردن دکوراتورها برای اطمینان از ایمنی نوعی در طول فرآیند تغییر، بسیار حیاتی هستند.

// یک دکوراتور کلاس ژِنریک که به کلاس پراپرتی 'timestamp' اضافه می‌کند
function AddTimestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        timestamp = new Date();
    };
}

@AddTimestamp
class EventLog {
    message: string;
    constructor(message: string) {
        this.message = message;
    }
}

interface EventLogWithTimestamp {
    message: string;
    timestamp: Date;
}

const logItem = new EventLog("User logged in") as EventLogWithTimestamp;
console.log(logItem.message);    // "User logged in"
console.log(logItem.timestamp);  // (Current Date Object)
// TypeScript recognizes the added 'timestamp' property due to the generic decorator signature.

در این دکوراتور `AddTimestamp`، `T extends { new (…args: any[]): {} }` یک محدودیت است که تضمین می‌کند `T` یک تابع سازنده (کلاس) است. دکوراتور یک کلاس جدید برمی‌گرداند که از کلاس اصلی `constructor` ارث‌بری می‌کند و یک پراپرتی جدید `timestamp` به آن اضافه می‌کند. تایپ‌اسکریپت به لطف ژِنریک‌ها، می‌تواند نوع کلاس جدید را به درستی استنتاج کند.

به کارگیری این بهترین روش‌ها و درک الگوهای پیشرفته، به شما کمک می‌کند تا از تمام پتانسیل ژِنریک‌ها در تایپ‌اسکریپت بهره‌مند شوید و کدی را بسازید که هم منعطف باشد و هم از نظر نوعی ایمن و قابل اعتماد.

نتیجه‌گیری: قدرت ژِنریک‌ها در توسعه مدرن تایپ‌اسکریپت

در طول این مقاله، به طور جامع به مفهوم ژِنریک‌ها در تایپ‌اسکریپت پرداختیم؛ از مبانی و متغیرهای نوعی گرفته تا کاربرد آن‌ها در توابع، اینترفیس‌ها، نوع‌های مستعار و کلاس‌ها. همچنین، اهمیت محدودیت‌های ژِنریک برای تعریف مرزهای نوعی و نقش کلیدی انواع ابزاری و انواع شرطی در گسترش قابلیت‌های سیستم نوعی تایپ‌اسکریپت را بررسی کردیم.

دیدیم که ژِنریک‌ها چگونه چالش‌های کدنویسی تکراری و از دست دادن ایمنی نوعی (ناشی از استفاده بی‌رویه از `any`) را حل می‌کنند. آن‌ها به ما امکان می‌دهند تا کدهایی را بنویسیم که:

  • قابل استفاده مجدد (Reusable) باشند: یک تابع یا کلاس را یک بار می‌نویسید و آن را برای انواع مختلفی از داده‌ها استفاده می‌کنید.
  • ایمن از نظر نوع (Type-Safe) باشند: تایپ‌اسکریپت می‌تواند نوع داده‌ها را در طول زمان کامپایل ردیابی کند و از خطاهای زمان اجرا جلوگیری نماید.
  • خواناتر و قابل نگهداری‌تر (Readable and Maintainable) باشند: با تعریف دقیق نوع‌ها، هدف کد شما واضح‌تر می‌شود و تغییرات آینده آسان‌تر است.
  • انعطاف‌پذیر (Flexible) باشند: قابلیت سازگاری با نیازهای متغیر پروژه‌ها و انواع داده‌های جدید را فراهم می‌کنند.

ژِنریک‌ها فقط یک ویژگی زبانی نیستند، بلکه یک پارادایم فکری در طراحی نرم‌افزار هستند که به شما کمک می‌کنند سیستم‌های قوی‌تر و مقیاس‌پذیرتری بسازید. درک عمیق آن‌ها برای هر توسعه‌دهنده تایپ‌اسکریپت که به دنبال نوشتن کد با کیفیت بالا است، ضروری است.

با تمرین و کاوش بیشتر، به تدریج می‌توانید به صورت طبیعی از ژِنریک‌ها در طراحی معماری‌های خود استفاده کنید. پیشنهاد می‌شود مثال‌های ارائه شده را خودتان اجرا کنید و با آن‌ها آزمایش کنید. سعی کنید سناریوهای مختلفی را در نظر بگیرید که در آن‌ها ژِنریک‌ها می‌توانند به حل مسائل شما کمک کنند. از انواع ابزاری موجود در تایپ‌اسکریپت الهام بگیرید و سعی کنید انواع ابزاری خودتان را برای نیازهای خاص پروژه‌تان بسازید.

قدرت ژِنریک‌ها نامحدود نیست، اما توانایی آن‌ها در افزایش انعطاف‌پذیری و ایمنی نوعی در کد، آن‌ها را به یکی از مهمترین و کاربردی‌ترین ویژگی‌های تایپ‌اسکریپت تبدیل می‌کند. با تسلط بر ژِنریک‌ها، گام بزرگی در جهت تبدیل شدن به یک توسعه‌دهنده تایپ‌اسکریپت حرفه‌ای و کارآمد برخواهید داشت.

“تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT”

قیمت اصلی 2.290.000 ریال بود.قیمت فعلی 1.590.000 ریال است.

"تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT"

"با شرکت در این دوره جامع و کاربردی، به راحتی مهارت‌های برنامه‌نویسی پایتون را از سطح مبتدی تا پیشرفته با کمک هوش مصنوعی ChatGPT بیاموزید. این دوره، با بیش از 6 ساعت محتوای آموزشی، شما را قادر می‌سازد تا به سرعت الگوریتم‌های پیچیده را درک کرده و اپلیکیشن‌های هوشمند ایجاد کنید. مناسب برای تمامی سطوح با زیرنویس فارسی حرفه‌ای و امکان دانلود و تماشای آنلاین."

ویژگی‌های کلیدی:

بدون نیاز به تجربه قبلی برنامه‌نویسی

زیرنویس فارسی با ترجمه حرفه‌ای

۳۰ ٪ تخفیف ویژه برای دانشجویان و دانش آموزان