DanLevy.net

טיפוסי גרילה ב-TypeScript

עיצוב גופנים מורד

Hero image for טיפוסי גרילה ב-TypeScript

טיפוסי גרילה ב-TypeScript

במאמר זה נחקור שלוש טכניקות מסקרנות (ואולי נוראיות?) שיסייעו בעיצוב טיפוסים!

המטרה העיקרית היא ממשקי עקביים וצפויים של מודל/ישות/מחלקה.

גישות לעיצוב טיפוסים

סביר להניח שנתקלתם או כתבתם תבניות שונות סביב “מימושים של טיפוסים”. במיוחד כשצורכים נתונים מ-API של צד שלישי.

הערה: אני מתעלם בכוונה מתהליכים “מסורתיים” של בניית דיאגרמות ישויות-קשרים (ERD) או היררכיות ירושה של תכנות מונחה עצמים (OOP). כאן, אנו בונים טיפוסים כדי לייצג נתוני API חצי-מובנים.

בואו נחקור שתי גישות ברמה גבוהה: אובייקט גדול יחיד (מלמעלה למטה) לעומת טיפוסים מרובים בעלי שם (מלמטה למעלה).

אובייקט גדול יחיד

מעניק עדיפות לבהירות מפורשת על פני שימוש חוזר ועקרון DRY.

בונוס: חוויית הפיתוח (IDE/Dev) מצוינת, מכיוון שתוויות העזר כוללות תצוגה מקדימה מלאה יותר – ללא טרחה.

interface ProductDetails {
name: string;
seller: { name: string };
availability: Array<{ warehouseId: string; quantity: number }>;
reviews: Array<{ authorId: number; stars: number }>;
}

מכיוון שאנו נותנים עדיפות לקריאות מפורשת, מותר להתפנק בקצת חזרתיות (במידה סבירה). כאשר קבוצות של מאפיינים חוזרות פעמים רבות, אל תהסס לחלץ את השדות החוזרים לטיפוס בעל שם.

טיפוסים מרובים בעלי שם

מעניק עדיפות לשימוש חוזר ועקרון DRY.

גישה זו היא ככל הנראה המועדפת בפער ניכר.

interface ProductDetails {
name: string;
seller: Seller;
reviews: Reviews[];
availability: Availability[];
}
interface Seller { name: string; }
interface Availability { warehouseId: string; quantity: number; }
interface Reviews { authorId: number; stars: number; }

בסך הכל, גישה זו מצוינת. אבל היא לא חפה מחסרונות.

⚠️ מאז (בערך) TypeScript v3, שרת השפה קוטע תוויות עזר, ומשמיט מאפיינים מקוננים. 💡 יש טריקים לשפר את המצב מעט. נסה להחזיק Cmd או Ctrl, ואז לרחף מעל שמות טיפוסים שונים – אתה אמור לראות לפחות ‘שכבה’ נוספת אחת של מאפיינים בתווית העזר.

למה אנחנו חייבים לבחור בין שתי הגישות האלה? (טיפוס גדול ומגושם לעומת תתי-טיפוס בעלי שם.)

טכניקה מס’ 1: למה לא הכל

האם אפשר לקבל הכל?

✅ כן! 🎉

export interface ProductDetails {
name: string;
seller: { name: string };
reviews: Array<{ authorId: number; stars: number }>;
availability: Array<{ warehouseId: string; quantity: number }>;
}
export type Seller = ProductDetails["seller"];
export type Review = ProductDetails["reviews"][number];
export type Availability = ProductDetails["availability"][number];
  1. צור טיפוסים “ראשיים” גדולים ומובנים.
  2. ייצא תת-טיפוסים הנגזרים מהטיפוס הראשי.

גישה זו באמת מצטיינת במערכות שבהן אובייקטים “ברמה גבוהה” נהנים מתיעוד במקום אחד. כמו כן, טכניקה זו תומכת בשימוש חוזר בין מקרי שימוש רבים: מודלים, שירותים, תוצאות שאילתות וכו’.

טכניקה #2: מיקסינים

אסטרטגיה זו עוסקת כולה בהרכבה של השדות הנכונים, עם השמות הנכונים, כדי לייצג אובייקטים לוגיים בודדים. המטרה היא לתת מענה יעיל למספר מקרי שימוש באמצעות TypeScript Utilities ו-Type Unions.

גישה זו שונה מהיררכיות וירושה מסורתיות של OOP, שמטרתן ליצור שכבות של אובייקטים בטקסונומיות הדוקות. גישת המיקסינים עוסקת בטיפוסים שטוחים וקשורים באופן רופף, תוך קיבוץ שדות קשורים תוך צמצום כפילויות.

דוגמאות למיקסינים

interface TodoModel {
text: string;
complete: boolean;
}
interface InstanceMixin {
id: number;
}
/** TodoDraft מייצג מצב של טופס, ייתכן שהכל undefined */
export type TodoDraft = Partial<TodoModel>;
/** Todo מייצג רשומת מופע Todo ממסד הנתונים */
export type Todo = TodoModel & InstanceMixin;

דוגמה User

interface User {
id: number;
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}

בואו נייצג את User לפני ואחרי שמירה למסד הנתונים.

// שדות ליבה של User (נניח עבור <form>)
interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// שדות ממסד הנתונים
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
// **מופע** User - עם כל השדות
type UserInstance = InstanceMixin & UserBase;

כעת נוכל לפסל בדיוק את השדות הדרושים לנו (כמו password ליצירה/עדכון, אך לא נכלל בשאילתות של UserInstance).

interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
/** מטען User להרשמה, כולל שדה `password` */
export type UserPayload = UserBase & { password: string };
/** מייצג את טיפוס User המוחזר מהשרת. */
export type UserInstance = UserBase & InstanceMixin;
  1. “האם זהו נוהג טוב?”
  2. “האם כדאי לי לנסות את זה?”

אין לי מושג. בואו נמשיך!

טכניקה מס’ 3: ארגון עם Namespaces

כאן, אנו מצהירים על namespace בשם ModelMixins. זה מספק ארגון מסוים בתוספת תבנית שימוש חוזר ברורה יותר.

צורות סטנדרטיות

// `src/types/mixins.d.ts`
namespace ModelMixins {
interface Identity {
id: number;
}
interface Timestamp {
createdAt: Date;
updatedAt: Date;
}
type Instance = ModelMixins.Identity & ModelMixins.Timestamp;
interface HashedPassword {
passwordHash: string;
}
interface InputPassword {
password: string;
}
}

שימוש ב-Type Unions

// `src/types/user.d.ts`
export interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// טיפוס `User` יחיד, המשתמש ב-Type Union כדי לייצג
// באופן דינמי את המצבים לפני ואחרי היצירה.
export type User =
| (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword)
| (UserBase & ModelMixins.InputPassword);

אם תרצו, תוכלו גם לייצא טיפוסים בעלי שם בנפרד:

/** מטען User להרשמה, כולל שדה `password` */
export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;
/** מייצג את טיפוס User המוחזר מהשרת. */
export type UserInstance = UserBase & ModelMixins.InputPassword;

שימוש בעולם האמיתי

הנה פונקציית upsert() המשתמשת באופרטור in כדי להבחין בין טיפוסי UserInstance ו-UserPayload.

function upsert(user: User) {
if ("id" in user) {
// TypeScript יודע ש-`user` כאן מכיל שדות מ-Instance (id, createdAt, וכו')
return updateUser(user.id, user);
} else {
// TypeScript יודע שזה חייב להיות הגרסה `UserBase & ModelMixins.InputPassword` של user.
return createUser(user);
}
}

סיכום

כיסינו שלוש טכניקות וכמה רעיונות תומכים קשורים.

ייתכן שאתם שואלים, האם אלו דפוסים טובים? האם כדאי לאמץ חלק מהרעיונות האלה?

משאבים