讓我休息一下。我還在學習!

嘿大家。我回來了。

而且,是的,我仍然犯著 n00b TypeScript 錯誤 😢

圖片描述

但幸運的是,當我繼續建立Open SaaS並使其成為 React 和 NodeJS 的最佳、免費、開源 SaaS 入門工具時,我有一些非常聰明的同事指出了一些很棒的 TypeScript 技巧。

https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sf1fhsgwuurkre9a7drq.png

今天我將與您分享這些技巧。

本系列 TypeScript 的第一部分中,我深入了解了它是什麼以及它如何運作的一些基礎知識。我還談到了satisfies關鍵字以及 TypeScript 結構類型系統的一些怪癖。

在本集中,我將教您如何使用巧妙的技術在大型應用程式(例如 SaaS 應用程式)中共享一組值,以確保您永遠不會忘記在新應用程式的其他部分時更新值已加入或更改。

那麼讓我們直接進入一些程式碼。

追蹤大型應用程式中的值

Open SaaS中,我們希望分配一些可以在整個應用程式(前端和後端)中使用的付款計劃值。例如,大多數 SaaS 應用程式都有一些可能銷售的不同產品計劃,例如:

  • 每月的Hobby訂閱計劃,

  • 每月Pro訂閱計劃,

  • 以及一次性支付產品,為用戶提供 10 個Credits他們可以在應用程式中兌換(而不是按月計劃)。

因此,使用enum並傳遞這些計劃值並保持它們一致似乎是個好主意:

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
}

然後,我們可以在定價頁面以及我們的伺服器端函數中使用此枚舉。

// ./client/PricingPage.tsx

import { PaymentPlanId } from '../payments/plans.ts'

export const planCards = [
  {
    name: 'Hobby',
    id: PaymentPlanId.Hobby,
    price: '$9.99',
    description: 'All you need to get started',
    features: ['Limited monthly usage', 'Basic support'],
  },
  {
    name: 'Pro',
    id: PaymentPlanId.Pro,
    price: '$19.99',
    description: 'Our most popular plan',
    features: ['Unlimited monthly usage', 'Priority customer support'],
  },
  {
    name: '10 Credits',
    id: PaymentPlanId.Credits10,
    price: '$9.99',
    description: 'One-time purchase of 10 credits for your account',
    features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'],
  },
];

export function PricingPage(props) {
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

在上面,您可以看到我們如何在定價頁面上使用枚舉作為付款計劃 ID。然後,我們將該 ID 傳遞給按鈕點擊處理程序,並將其在請求中傳送到伺服器,以便我們知道要處理哪個付款計劃。

圖片描述

// ./server/Payments.ts

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (plan, context) => {
  let stripePriceId;
  if (plan === PaymentPlanId.Hobby) {
    stripePriceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Pro) {
    stripePriceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Credits10) {
    stripePriceId = process.env.STRIPE_CREDITS_PRICE_ID!;
  } else {
    throw new HttpError(404, 'Invalid plan');
  }

  //...

這裡使用枚舉的好處是它很容易在整個應用程式中一致地使用。在上面的範例中,我們使用它來將我們的定價計劃對應到我們在 Stripe 上建立這些產品時給出的價格 ID,並將其儲存為環境變數。

但對於我們目前的程式碼,如果我們決定建立一個新計劃(例如 50 Credit 一次性付款計劃)並將其加入到我們的應用程式中,會發生什麼情況?

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
  Credits50 = 'credits50'
}

目前,我們必須瀏覽應用程式,找到使用PaymentPlanID的每個位置,並新增對新Credits50計劃的引用。

// ./client/PricingPage.tsx

import { PaymentPlanId } from '../payments/plans.ts'

export const planCards = [
  {
    name: 'Hobby',
    id: PaymentPlanId.Hobby,
    //...
  },
  {
    name: 'Pro',
    id: PaymentPlanId.Pro,
    price: '$19.99',
    //...
  },
  {
    name: '10 Credits',
    id: PaymentPlanId.Credits10,
    //...
  },
  {
    name: '50 Credits',
    id: PaymentPlanId.Credits50.
    //...
  }
];

export function PricingPage(props) {
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

// ./server/Payments.ts

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (plan, context) => {
  let stripePriceId;
  if (plan === PaymentPlanId.Hobby) {
    stripePriceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
  } else if (plan === PaymentPlanId.Pro) {
    //..
  } else if (plan === PaymentPlanId.Credits50) {
    stripePriceId = process.env.STRIPE_CREDITS_50_PRICE_ID!; // ✅
  } else {
    throw new HttpError(404, 'Invalid plan');
  }

好的。這看起來似乎不太困難,但是如果您在兩個以上的文件中使用PaymentPlanId該怎麼辦?您很有可能會忘記在某個地方引用您的新付款計劃!

如果 TypeScript 能夠在我們忘記將其加入到某處時告訴我們,那不是很酷嗎?這正是Record類型可以幫助我們解決的問題。

讓我們來看看。

使用記錄類型保持值同步

首先, Record是一種幫助我們鍵入物件的實用程式類型。透過使用Record我們可以準確地定義我們的鍵和值應該是什麼類型。

物件上的Record<X, Y>類型表示「該物件文字必須為 X 類型的每個可能值定義一個 Y 類型的值」。換句話說,記錄強制執行編譯時檢查以確保詳盡性。

實際上,這意味著當有人向枚舉PaymentPlanId加入新值時,編譯器不會讓他們忘記加入適當的映射

這使我們的物件映射保持強大和安全。

讓我們看看它如何與我們的PaymentPlanId枚舉一起使用。讓我們先看看如何使用Record類型來確保定價頁面上始終包含所有付款計劃:

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
}

// ./client/PricingPage.tsx

export const planCards: Record<PaymentPlanId, PaymentPlanCard> = {
  [PaymentPlanId.Hobby]: {
    name: 'Hobby',
    price: '$9.99',
    description: 'All you need to get started',
    features: ['Limited monthly usage', 'Basic support'],
  },
  [PaymentPlanId.Pro]: {
    name: 'Pro',
    price: '$19.99',
    description: 'Our most popular plan',
    features: ['Unlimited monthly usage', 'Priority customer support'],
  },
  [PaymentPlanId.Credits10]: {
    name: '10 Credits',
    price: '$9.99',
    description: 'One-time purchase of 10 credits for your account',
    features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'],
  }
};

export function PricingPage(props) {
  return (
    ...
      planCards.map(planCard => {
        <PlanCard card={planCard} />
      })
    ...
  )
}

現在planCards是一個Record類型,其中鍵必須是PaymentPlanId ,值必須是包含付款計劃資訊 ( PaymentPlanCard ) 的物件。

當我們為枚舉新增價值(例如Credits50時,神奇的事情就會發生:

export enum PaymentPlanId {
  Hobby = 'hobby',
  Pro = 'pro',
  Credits10 = 'credits10',
  Credits50 = 'credits50'
}

圖片描述

現在 TypeScript 給我們一個編譯時錯誤, Property '[PaymentPlanId.Credits50]' is missing... ,讓我們知道我們的定價頁面不包含新計劃的卡。

現在您會看到使用Record保持值一致的簡單功能。但我們不應該只在前端執行此操作,讓我們修復處理不同計劃付款的伺服器端功能:

// ./payments/plans.ts
export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
  [PaymentPlanId.Hobby]: {
    stripePriceId: process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Pro]: {
    stripePriceId: process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Credits10]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits', 
    amount: 10
  },
  [PaymentPlanId.Credits50]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits', 
    amount: 50
  },
};

// ./server/Payments.ts
import { paymentPlans } from './payments/plans.ts'

export const stripePayment: StripePayment<PaymentPlanId, StripePaymentResult> = async (planId, context) => {
  const priceId = paymentPlans[planId].stripePriceId

  //...

這項技術真正酷的是,透過使用我們的PaymentPlanId枚舉作為鍵值的Record類型定義paymentPlans ,我們始終可以確保我們永遠不會忘記任何付款計劃或犯下愚蠢的拼寫錯誤。 TypeScript 將拯救我們。

另外,我們可以將整個if else區塊換成乾淨的一行:

const priceId = paymentPlans[planId].stripePriceId

順利:)

我們也很可能會在程式碼的其他地方使用paymentPlans物件,使其更乾淨且更易於維護。真正的三贏局面,得益於Record類型。

優先使用Record映射而if else

為了進一步說明Record如何讓我們作為開發人員的生活變得更輕鬆,讓我們來看看另一個在客戶端使用它來顯示一些使用者帳戶資訊的範例。

首先,讓我們總結一下我們的應用程式中發生的情況以及我們如何使用我們友好的實用程式類型:

  1. 我們定義了PaymentPlanId枚舉來集中我們的付款計劃 ID 並使其在整個應用程式中保持一致。

  2. 我們在客戶端和伺服器程式碼中使用Record映射物件,以確保我們所有的付款計劃都存在於這些對像中,這樣,如果我們加入新的付款計劃,我們將收到TypeScript 警告,表明它們也必須加入到這些物件中。

現在,我們在前端使用這些 ID,並將它們傳遞到伺服器端呼叫,以便在使用者點擊Buy Plan按鈕時處理正確計劃的付款。當使用者完成付款時,我們將該PaymentPlanId儲存到資料庫中使用者模型的屬性中,例如user.paymentPlan

現在讓我們看看如何再次使用該值以及與Record類型映射的物件,以比if elseswitch塊更乾淨、更類型安全的方式有條件地檢索帳戶資訊:

// ./client/AccountPage.tsx

export function AccountPage({ user }: { user: User }) {
  const paymentPlanIdToInfo: Record<PaymentPlanId, string> = {
    [PaymentPlanId.Hobby]: 'You are subscribed to the monthly Hobby plan.',
    [PaymentPlanId.Pro]: 'You are subscribed to the monthly Pro plan.',
    [PaymentPlanId.Credits10]: `You purchased the 10 Credits plan and have ${user.credits} left`,
    [PaymentPlanId.Credits50]: `You purchased the 50 Credits plan and have ${user.credits} left`
  };

  return (
    <div>{ paymentPlanIdToInfo[user.paymentPlan] }</div>
  )
}

同樣,我們所要做的就是更新PaymentPlanId枚舉以包含我們可能建立的任何其他付款計劃,TypeScript 會警告我們需要將其加入到用作Record鍵或值類型的所有映射中。

相較之下,如果我們使用if else區塊,則不會收到此類警告。我們也無法防止愚蠢的拼字錯誤,導致程式碼錯誤更多、更難維護:

export function AccountPage({ user }: { user: User }) {
  let infoMessage = '';

  if(user.paymentPlan === PaymentPlanId.Hobby) {
    infoMessage = 'You are subscribed to the monthly Hobby plan.';

  // ❌ We forgot the Pro plan here, but will get no warning from TS!

  } else if(user.paymentPlan === PaymentPlanId.Credits10) { 
    infoMessage = `You purchased the 10 Credits plan and have ${user.credits} left`;

  // ❌ Below we used the wrong user property to compare to PaymentPlanId.
  // Although it's acceptable code, it's not the correct type!
  } else if(user.paymentStatus === PaymentPlanId.Credits50) {
    infoMessage = `You purchased the 50 Credits plan and have ${user.credits} left`;
  }

  return (
    <div>{ infoMessage }</div>
  )
}

但有時我們需要更複雜的條件檢查以及單獨處理任何附帶情況的能力。在這種情況下,我們最好使用if elseswitch語句。

那麼我們要如何才能獲得與Record映射相同的類型檢查徹底性,同時又具有if elseswitch的優點呢?


順便一提…

我們Wasp正在努力建立最好的開源 React/NodeJS 框架,讓您快速行動!

這就是為什麼我們透過簡單的 CLI 命令提供了即用型全端應用程式模板,例如Open SaaS或帶有 TypeScript 的 ToDo 應用程式。您所要做的就是安裝 Wasp:

curl -sSL https://get.wasp-lang.dev/installer.sh | sh

並執行:

wasp new -t saas
# or 
wasp new -t todo-ts

圖片描述

您將獲得開箱即用的帶有 Auth 和端到端 TypeSafety 的全端模板,以幫助您學習 TypeScript,或讓您開始快速、安全地建立可獲利的副專案:)


使用never …有時

上述問題的答案是,我們需要一種方法來檢查switch語句中的「詳盡性」。讓我們使用下面的範例:

  // ./payments/Stripe.ts

  const plan = paymentPlans[planId];

  let subscriptionPlan: PaymentPlanId | undefined;
  let numOfCreditsPurchased: number | undefined;

  switch (plan.kind) {
    case 'subscription':
      subscriptionPlan = planId;
      break;
    case 'credits':
      numOfCreditsPurchased = plan.effect.amount;
      break;
  } 

我們在這裡使用了一個相對簡單的switch語句,而不是與Record類型的映射,因為用這種方式分配兩個變數的值( subscriptionPlannumOfCreditsPurchased )更乾淨、更容易閱讀。

但現在我們已經失去了透過Record類型映射獲得的詳盡類型檢查,因此,如果我們要加入一個新的plan.kind ,例如metered-usage ,我們將不會在switch中收到來自 TypeScript 的警告上面的聲明。

噓!

幸運的是,有一個簡單的解決方案。我們可以建立一個實用函數來為我們檢查:

export function assertUnreachable(x: never): never {
  throw Error('This code should be unreachable');
}

這可能看起來很奇怪,但重要的是使用never類型。它告訴 TypeScript 這個值「永遠」不應該出現。

為了讓我們了解這個實用函數是如何運作的,讓我們現在繼續加入我們的新計劃kind

// ./payments/plans.ts
export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
  [PaymentPlanId.Hobby]: {
    stripePriceId: process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Pro]: {
    stripePriceId: process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID,
    kind: 'subscription'
  },
  [PaymentPlanId.Credits10]: {
    stripePriceId: process.env.STRIPE_CREDITS_PRICE_ID,
    kind: 'credits', 
    amount: 10
  },
  // ✅ Our new payment plan kind
  [PaymentPlanId.MeteredUsage]: {
    stripePriceId: process.env.STRIPE_METERED_PRICE_ID,
    kind: 'metered-usage'
};

現在,如果我們加入assertUnreachable ,看看會發生什麼:

圖片描述

啊哈!我們收到錯誤Argument of type '{ kind: "metered-usage"; }' is not assignable to parameter of type 'never'

完美的。我們在switch語句中引入了詳盡的類型檢查。這段程式碼實際上並不是要執行的,它只是提前為我們提供友好的警告。

在這種情況下,為了讓 TypeScript 不再生我們的氣,我們要做的就是…:

  switch (plan.kind) {
    case 'subscription':
      subscriptionPlan = planId;
      break;
    case 'credits':
      numOfCreditsPurchased = plan.effect.amount;
      break;
    // ✅ Add our new payment plan kind
    case 'metered-usage'
      currentUsage = getUserCurrentUsage(user);
      break;
    default:
      assertUnreachable(plan.kind);
  } 

這很棒。我們獲得了在switch語句中處理更複雜的邏輯的所有好處,並保證我們永遠不會忘記我們的應用程式中使用的任何可能的plan.kind情況。

像這樣的東西使得程式碼更不容易出錯,並且更容易除錯。一點點準備會很有幫助!

繼續 TypeScript 的故事

這是本系列的第 2 部分,“我一直在使用 TypeScript,但並不理解它”,其中我分享了我在建置和維護Open SaaS(一個完全免費的開源軟體)時向朋友和同事學習TypeScript 細節的旅程。

我盡力讓Open SaaS盡可能專業、功能齊全,但又不會太複雜,並以輕鬆的方式分享我在這個過程中學到的東西。如果您發現此過程有任何令人困惑的地方,請在評論中告訴我們,我們將盡力澄清。

另外,如果您喜歡我們在這裡所做的事情,無論是文章還是Open SaaS ,請告訴我們並考慮在 GitHub 上給我們一顆星!它有助於激勵我們並為您帶來更多這樣的東西。

謝謝,我們下一篇文章見。


原文出處:https://dev.to/wasp/ive-been-writing-typescript-without-understanding-it-pt-2-17af


共有 0 則留言