برنامه نویسی

TypeScript Wrapper: ورودی های اختیاری و انواع خروجی پویا

در حالی که بسیاری از مقالات نحوه نوشتن یک لفاف را توضیح می دهند، در این مقاله نحوه کنترل کامل نوع آن را به شما نشان خواهیم داد. اما ابتدا بیایید تعریف کنیم که wrapper چیست:

Wrapper تابعی است که عملکرد دیگری را در بر می گیرد. هدف اصلی آن اجرای منطق اضافی است که عملکرد اصلی آن را انجام نمی دهد، معمولاً برای کمک به کاهش کد دیگ بخار.

هنگامی که به درستی انجام شد، می توانید گزینه هایی را به wrapper خود اضافه کنید که به صورت پویا انواع خروجی آن را تعیین می کند. این بدان معنی است که شما می توانید یک بسته بندی کاملاً قابل تنظیم و ایمن بسازید که تجربه توسعه شما را افزایش می دهد.

قبل از اینکه شروع کنیم

تمام نمونه های این مقاله نیز TypeScript Playground ارائه شده است. با توجه به اینکه این مقاله حول یک مثال عملی متمرکز شده است، من مجبور شدم عملکردها و انواع خاصی را به سخره بگیرم تا نحوه کار آنها را نشان دهم. کد ارائه شده در زیر در بیشتر نمونه ها استفاده می شود، اما به طور مکرر در هر مثال جداگانه گنجانده نمی شود تا خواندن مطالب را آسان تر کند:

// These should be Next.js types, typically imported from next.
type NextApiRequest = { body: any }
type NextApiResponse = { json: (arg: unknown) => void }

// Under normal circumstances, this function would return a unique ID for a logged-in user.
const getSessionUserId = (): number | null => {
  return Math.random() || null
}

// This function is utilized to parse the request body and perform basic validation.
const parseNextApiRequestBody = <B = object>(
  request: NextApiRequest
): Partial<B> | null => {
  try {
    const parsedBody = JSON.parse(request.body as string) as unknown
    return typeof parsedBody === 'object' ? parsedBody : null
  } catch {
    return null
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

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

type Expand<T> = T extends ((...args: any[]) => any) | Date | RegExp
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? Map<Expand<K>, Expand<V>>
  : T extends ReadonlySet<infer U>
  ? Set<Expand<U>>
  : T extends ReadonlyArray<unknown>
  ? `${bigint}` extends `${keyof T & any}`
    ? { [K in keyof T]: Expand<T[K]> }
    : Expand<T[number]>[]
  : T extends object
  ? { [K in keyof T]: Expand<T[K]> }
  : T
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این نوع ابزار توسط kelsny در نظر Stack Overflow ارائه شده است. همانطور که در موضوع بحث شد، آماده تولید نیست و صرفاً به عنوان یک تابع کاربردی برای توسعه آسان انواع عمل می کند. بدون Expand، نمی‌توانیم ویژگی‌های نوع فردی را مستقیماً در نمونه‌های TypeScript Playground مشاهده کنیم.

ابزار دوم است // ^? نحو، که ممکن است بسیاری از مردم با آن ناآشنا باشند. این یک ویژگی منحصر به فرد TypeScript Playground است که به صورت پویا نوع متغیری را نشان می دهد که به آن می شود ^ نشان داده شده است (بالا)، که باعث می شود با تغییر کد، ردیابی انواع خود را آسان تر کنید. هنگام استفاده با Expand، می تواند برای عیب یابی انواع شما بسیار مفید باشد.

⚠ به طور معمول، ما همه wrapper ها را در یک فایل کمکی قرار می دهیم (به عنوان مثال، /src/api.helper.ts) بنابراین می توان آنها را در همه APIها مورد استفاده مجدد قرار داد. با این حال، به خاطر این مقاله، همه کدها را در یک فایل قرار می دهیم تا به راحتی آن را در عمل نشان دهیم.

مثال عملی ما

ما اخیراً هنگام تلاش برای بهبود بسته‌بندی API Next.js با یک چالش TypeScript مواجه شدیم. از آنجایی که مثال خوبی ارائه می‌کند، و هم کاربردی و هم قابل درک است، در طول این مقاله از آن استفاده خواهیم کرد. نگران نباشید، لازم نیست چیزی در مورد Next.js بدانید. تمرکز این مقاله نیست.

برای کسانی که با Next.js آشنایی ندارند، در اینجا نحوه تعریف یک API آورده شده است: ایجاد یک فایل در /pages/api/hello.tsx، کد زیر را کپی و جایگذاری کنید، و به درستی خواهید داشت {"hello": "world"} API.

import { NextApiRequest, NextApiResponse } from 'next'

export default async (
  request: NextApiRequest,
  response: NextApiResponse
): Promise<void> => {
  return void response.json({ hello: 'world' })
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این رویکرد برای برنامه های کوچک کاملاً خوب عمل می کند. اما چه اتفاقی می‌افتد هنگامی که برنامه شما رشد می‌کند و شروع به داشتن APIهای متعددی می‌کنید که همان منطق را به طور مکرر انجام می‌دهند؟ به طور معمول، اکثر توسعه دهندگان یک لفاف در بالا می نویسند تا منطق تکراری را مدیریت کنند.

نیاز به لفاف

به عنوان مثال، فرض کنید که برخی از APIهایی داریم که نیاز به احراز هویت دارند و برخی دیگر که نیازی به احراز هویت ندارند. ما یک لفاف می خواهیم که این منطق را مدیریت کند. در اینجا یک راه برای انجام این کار وجود دارد:

مثال کامل در TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
}

type CallbackOptions<O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O['requiresAuthentication'] extends true ? { userId: string } : object)

export const handleRequest =
  <O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    return callback({
      request,
      response,
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<O>)
  }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

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

به عنوان مثال، اگر شما استفاده کنید handleRequest بدون هیچ گزینه ای مانند این:

export default handleRequest({}, async (options) => {
  // some API code here...
})
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون، options فقط شامل request و response، که کم و بیش معادل نداشتن لفاف است. با این حال، هنگامی که از آن با یک گزینه استفاده می کنید، به طور قابل توجهی مفیدتر می شود:

export default handleRequest(
  { requiresAuthentication: true },
  async (options) => {
    // some API code here...
  }
)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

options شامل می شود request، response، و userId. اگر کاربر وارد نشده باشد، کد داخل wrapper اجرا نخواهد شد.

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

آن را یک قدم جلوتر می برد

بیایید این را یک قدم جلوتر ببریم. اگر بخواهیم wrapper به صورت اختیاری بدنه درخواست را تجزیه کند و نوع صحیح را برگرداند، چه؟ ما می توانیم این کار را به صورت زیر انجام دهیم:

مثال کامل در TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
  parsedRequestBody: B
} & (O['requiresAuthentication'] extends true ? { userId: string } : object)

export const handleRequest =
  <B = never, O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<B, O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    return callback({
      request,
      response,
      parsedRequestBody: {} as B,
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با معرفی یک ژنریک جدید B که بتوانیم به آن پاس کنیم handleRequest، اکنون می توانیم نوع payload را که در هنگام فراخوانی API در بدنه ارسال می شود، مشخص کرده و نوع مربوط به آن را دریافت کنیم. مثلا:

export default handleRequest<{ hello: string }>({}, async (options) => {
  // some API code here...
})
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در این مورد، options شامل می شود request، response، و parsedRequestBody. این parsedRequestBody از نوع است { hello: string }. با این حال، چالش در حال حاضر این است که ما فقط نوع عمومی را پیاده سازی کرده ایم. ما منطقی را وارد نکرده ایم که بررسی کند آیا این گزینه وجود دارد یا خیر. برای انجام این کار باید یک گزینه جدید اضافه کنیم، همانطور که در زیر نشان داده شده است:

مثال کامل در TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
  parseBody?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O['requiresAuthentication'] extends true ? { userId: string } : object) &
  (O['parseBody'] extends true ? { parsedRequestBody: B } : object)

export const handleRequest =
  <B = never, O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<B, O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    // Check if the request's body is valid.
    const parsedRequestBody = options.parseBody
      ? parseNextApiRequestBody<O>(request)
      : undefined
    if (options.parseBody && !parsedRequestBody) {
      return void response.json({ error: 'invalid payload' })
    }

    return callback({
      request,
      response,
      ...(options.parseBody ? { parsedRequestBody } : {}),
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

هنگامی که ما این گزینه تماس جدید را اضافه می کنیم (O['parseBody'] extends true ? { parsedRequestBody: B } : object)، اگر parseBody گزینه تنظیم شده است true، باید گزینه جدیدی به نام بدست آوریم parsedRequestBody، که حامل نوع ژنریک است B. این دقیقاً از همان منطقی پیروی می کند که ما برای آن انجام دادیم requiresAuthentication. با این حال، تنها تفاوت این است که از یک ژنریک استفاده می کند. ما می توانیم سعی کنیم آن را به صورت زیر پیاده سازی کنیم:

export default handleRequest<{ hello: string }>(
  { parseBody: true },
  async (options) => {
    // some API code here...
  }
)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

هنگام بازرسی options، ما به سرعت آن را پیدا می کنیم parsedRequestBody در دسترس نیست. اما چرا؟ ما دقیقاً از همان منطقی استفاده می کنیم که مورد استفاده قرار می گیرد requiresAuthentication، که با استفاده از کد زیر همچنان کار می کند:

export default handleRequest(
  { requiresAuthentication: true },
  async (options) => {
    // some API code here...
  }
)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

چه خبره؟

وقتی تا حدی پارامترهای عمومی را در TypeScript مشخص می‌کنید، بقیه پارامترها به جای استنباط از استفاده، به مقادیر پیش‌فرض خود برمی‌گردند. این اتفاق می افتد با handleRequest تابع. وقتی یک نوع برای B پارامتر (نوع بدن) اما نه برای O پارامتر (نوع گزینه ها)، TypeScript به حالت پیش فرض باز می گردد Options برای تایپ کنید O.

در Options نوع، parseBody و requiresAuthentication ویژگی‌های اختیاری هستند که انواع آن‌ها به صورت پیش‌فرض است boolean | undefined. وقتی این ویژگی ها به صراحت مشخص نشده باشند، TypeScript نوع پیش فرض آنها را به آنها اختصاص می دهد. boolean | undefined، که از نوع اتحادیه است.

در CallbackOptions نوع، parsedRequestBody و userId فیلدها به صورت مشروط بر اساس اینکه آیا گنجانده می شوند O['parseBody'] و O['requiresAuthentication'] توسعه دادن، گسترش true.

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

بنابراین، با رفتار فعلی TypeScript، استنتاج عمومی اساساً یک رویکرد “همه یا هیچ” است.

یک مشکل قدیمی (2016) GitHub وجود دارد که در مورد این موضوع خاص در اینجا بحث می کند: https://github.com/microsoft/TypeScript/issues/10571

چگونه می توان با محدودیت استنتاج پارامترهای عمومی جزئی در TypeScript مقابله کرد؟

دو راه حل اصلی که به ذهن می رسد معمولاً به شرح زیر است:

  • تعیین تمام پارامترهای عمومی: هنگام استفاده handleRequest، می توانید تمام انواع خود را به طور صریح تعریف کنید، همانطور که نشان داده شده است: handleRequest<{ hello: string }, { requiresAuthentication: true }>({ requiresAuthentication: true }, async (options) => {. در حالی که این ممکن است کار کند، شما را مجبور می کند همه پارامترها را مشخص کنید، حتی آنهایی که در حال حاضر از آنها استفاده نمی کنید. این می‌تواند منجر به تجربه کمتر از بهینه توسعه‌دهنده شود، زیرا ممکن است با افزایش تعداد گزینه‌ها، خواندن و نگهداری کد سخت‌تر شود. برای نمایش، به این زمین بازی TypeScript نگاهی بیندازید.
  • ایجاد توابع اختصاصی: به جای داشتن یک اندازه برای همه handleRequest، می توانید توابع سفارشی مانند ایجاد کنید handleRequestWithAuthAndBody و handleRequestWithAuthو غیره. اشکالی که در اینجا وجود دارد، احتمال تکرار قابل توجه کد است. با گسترش گزینه های شما، حفظ کد می تواند به یک کار دلهره آور تبدیل شود. برای یک نگاه عملی، این مثال را در TypeScript Playground بررسی کنید.

بنابراین، آیا راه حل بهینه ای برای این مشکل وجود دارد؟ به نظر می رسد که هر رویکردی با مجموعه ای از چالش ها همراه است، که با توجه به اینکه این یک مانع رایج در پروژه های بزرگتر است، کمی تعجب آور است.

در اینجاست که تکنیکی به نام «کاری کردن» وارد عمل می‌شود (و اتفاقاً دلیل اصلی نوشتن این مقاله است، زیرا این راه حل به طور گسترده شناخته شده نیست). با تشکر از رایان براون برای ابداع این رویکرد همانطور که ما با مشکل مواجه شدیم.

مردی در حال خوردن کاری قرمز داغ.

“کاری کردن” چیست؟

Currying یک تکنیک در برنامه نویسی تابعی است که در آن یک تابع با چندین آرگومان به دنباله ای از توابع تبدیل می شود که هر کدام دارای یک آرگومان واحد هستند. به عنوان مثال، تابعی که سه پارامتر را می گیرد، curriedFunction(x, y, z)، تبدیل می شود (x) => (y) => (z) => { /* function body */ }.

استفاده از Currying برای رفع محدودیت استنتاج پارامتر عمومی جزئی

کارینگ می تواند راه حلی برای این مشکل باشد. با تقسیم کردن handleRequest به دو بخش که هر کدام یک آرگومان را می پذیرند، به TypeScript اجازه می دهیم تا انواع را در دو مرحله استنتاج کند. تابع اول می گیرد options آرگومان، و تابعی را برمی گرداند که تابعی را می گیرد callback بحث و جدل. به این ترتیب، TypeScript زمینه لازم برای استنباط نوع صحیح را در هنگام فراخوانی تابع برگشتی دارد.

مثال کامل در TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
  parseBody?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O extends { requiresAuthentication: true } ? { userId: string } : object) &
  (O extends { parseBody: true } ? { parsedRequestBody: B } : object)

const handleRequest =
  <O extends Options>(options: O) =>
  <B = never>(callback: (options: CallbackOptions<B, O>) => Promise<void>) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()

    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    // Check if the request's body is valid.
    const parsedRequestBody = options.parseBody
      ? parseNextApiRequestBody(request)
      : undefined

    if (options.parseBody && !parsedRequestBody) {
      return void response.json({ error: 'invalid payload' })
    }

    return callback({
      request,
      response,
      ...(options.parseBody ? { parsedRequestBody } : {}),
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

به این ترتیب، ما به اصل “همه یا هیچ” (یا این یک محدودیت است؟) TypeScript پایبند هستیم. ما می توانیم از همان wrapper برای تجزیه بدنه درخواست استفاده کنیم:

export default handleRequest({ parseBody: true })<{
  hello: string
}>(async (options) => {
  // some API code here...
})
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

یا بررسی کنید که آیا کاربر وارد شده است:

export default handleRequest({ requiresAuthentication: true })(
  async (options) => {
    // some API code here...
  }
)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

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

نتیجه

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

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

همچنین ببینید
بستن
دکمه بازگشت به بالا