发布于 2025-03-18 15 阅读
0

我一直在写 TypeScript,但我并不理解它——第 2 部分

让我休息一下。我还在学习!

大家好,我回来了。

是的,我仍然会犯一些 TypeScript 新手错误 😢

图片描述

但幸运的是,我有一些非常聪明的同事,他们指出了一些很棒的 TypeScript 技巧,同时我继续构建Open SaaS并使其成为 React 和 NodeJS 的最佳、免费、开源 SaaS 启动器。

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

今天我要与你们分享这些技巧。

在本系列关于 TypeScript 的文章的第一部分中,我介绍了 TypeScript 的一些基础知识以及它的工作原理。我还谈到了satisfies关键字和 TypeScript 结构化类型系统的一些怪癖。

在本集中,我将教您如何使用一种巧妙的技术在大型应用程序(例如 SaaS 应用程序)中共享一组值,以确保您永远不会忘记在添加或更改新值时更新应用程序的其他部分。

那么让我们直接进入一些代码。

跟踪大型应用程序中的值

Open SaaS中,我们希望分配一些可在整个应用程序(包括前端和后端)中使用的付款计划值。例如,大多数 SaaS 应用程序都有可能销售几种不同的产品计划,例如:

  • 每月Hobby订阅计划,

  • 每月Pro订阅计划,

  • 以及一次性付款产品,用户Credits可以在应用程序中兑换 10 美元(而不是按月付款)。

因此,使用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 信用一次性付款计划)并将其添加到我们的应用程序中,会发生什么?



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类型可以帮助我们解决的问题。

让我们来看看。

使用记录类型保持值同步

首先,aRecord是一种实用类型,可以帮助我们确定对象的类型。通过使用,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

  //...


这种技术真正酷的地方在于,通过定义使用枚举作为键值的类型,我们可以确保我们永远不会忘记任何付款计划或犯愚蠢的打字错误。 TypeScript 会在这里拯救我们paymentPlansRecordPaymentPlanId

另外,我们可以将整个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 else或的好处呢switch


顺便一提…

我们正在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,我们在上面语句中不会收到来自 TypeScript 的警告switch

噓!

幸运的是,有一个简单的解决方案。我们可以创建一个实用函数来为我们进行检查:



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 的故事

这是本系列的第二部分“我一直在使用 TypeScript 但并不理解它”,在其中我分享了我在构建和维护Open SaaS(一个完全免费的开源 SaaS 入门模板)时从朋友和同事那里学习 TypeScript 的细节的历程。

我正在尽我所能使Open SaaS尽可能专业和功能齐全,而不会使其过于复杂,并以轻松的方式分享我在此过程中学到的东西。如果您发现此过程中有任何令人困惑的地方,请在评论中告诉我们,我们会尽力澄清。

此外,如果您喜欢我们在这里所做的事情,无论是文章还是Open SaaS,请告诉我们,并考虑在 GitHub 上给我们一个星星!这有助于激励我们并为您带来更多这样的东西。

谢谢,我们下篇文章再见。