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

2025-05-28

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

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

大家好,我回来了。

是的,我仍然会犯一些 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',
}


Enter fullscreen mode Exit fullscreen mode

然后,我们可以在定价页面以及服务器端功能中使用这个枚举。



// ./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} />
      })
    ...
  )
}


Enter fullscreen mode Exit fullscreen mode

上图展示了我们如何在定价页面上使用枚举作为付款计划 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');
  }

  //...


Enter fullscreen mode Exit fullscreen mode

在这里使用枚举的好处是,它很容易在整个应用程序中保持一致。在上面的例子中,我们用它将定价计划映射到我们在 Stripe 上创建这些产品时赋予的价格 ID,这些价格 ID 我们已保存为环境变量。

但是使用我们当前的代码,如果我们决定创建一个新计划(例如 50 信用一次性付款计划)并将其添加到我们的应用程序中,会发生什么?



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


Enter fullscreen mode Exit fullscreen mode

嗯,目前,我们必须浏览应用程序,找到我们正在使用的每个地方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');
  }


Enter fullscreen mode Exit fullscreen mode

好的。这看起来似乎不太难,但如果你使用的PaymentPlanId不止两个文件怎么办?你很可能会忘记在某个地方引用你的新付款计划!

如果我们忘记在某个地方添加它,TypeScript 会提醒我们,那不是很酷吗?这正是Record类型可以帮助我们解决的问题。

让我们来看看。

使用记录类型保持值同步

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


Enter fullscreen mode Exit fullscreen mode

现在planCards是一种Record类型,其中键必须是PaymentPlanId,并且值必须是具有付款计划信息的对象(PaymentPlanCard)。

当我们向枚举添加新值时,就会发生神奇的事情,例如Credits50



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


Enter fullscreen mode Exit fullscreen mode

图片描述

现在 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

  //...


Enter fullscreen mode Exit fullscreen mode

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

另外,我们可以将整个if else块换成一行简洁的代码:



const priceId = paymentPlans[planId].stripePriceId


Enter fullscreen mode Exit fullscreen mode

非常顺滑 :)

我们也很可能会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>
  )
}


Enter fullscreen mode Exit fullscreen mode

同样,我们所要做的就是更新我们的PaymentPlanId枚举以包含我们可能创建的任何其他付款计划,并且 TypeScript 会警告我们需要将其添加到所有用作Record键或值类型的映射中。

相比之下,如果我们使用if else块,就不会收到这样的警告。我们也无法防范愚蠢的拼写错误,从而导致代码更加 bug 满满,更难维护:



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>
  )
}


Enter fullscreen mode Exit fullscreen mode

但有时我们需要更复杂的条件检查,并能够单独处理任何附带情况。在这种情况下,使用if elseorswitch语句肯定更好。

那么,我们如何才能获得与映射相同的类型检查彻底性Record,但又具有if else或的好处呢switch


顺便一提…

我们正在Wasp努力创建最好的开源 React/NodeJS 框架,让您快速行动!

因此,我们提供了即用型全栈应用模板,只需一个简单的 CLI 命令即可使用,例如Open SaaS或使用 TypeScript 的 ToDo 应用。您只需安装 Wasp 即可:



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


Enter fullscreen mode Exit fullscreen mode

并运行:



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


Enter fullscreen mode Exit fullscreen mode

图片描述

您将获得一个带有 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;
  } 


Enter fullscreen mode Exit fullscreen mode

这里我们用一个相对简单的switch语句来代替类型的映射,因为这样分配两个变量Record的值更加清晰,也更容易阅读。subscriptionPlannumOfCreditsPurchased

但是现在我们失去了通过类型映射获得的详尽类型检查Record,因此如果我们要添加一个新的plan.kind,例如metered-usage,我们在上面的语句中不会收到来自 TypeScript 的警告switch

噓!

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



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


Enter fullscreen mode Exit fullscreen mode

这看起来可能有点奇怪,但重要的是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'
};


Enter fullscreen mode Exit fullscreen mode

现在,如果我们添加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);
  } 


Enter fullscreen mode Exit fullscreen mode

这太棒了。我们既能享受到用语句处理更复杂逻辑的所有好处switch,又能保证永远不会忘记plan.kind应用中可能用到的任何情况。

像这样的东西可以大大降低代码出错的可能性,也更容易调试。一点点准备就能带来很大的帮助!

继续 TypeScript 的故事

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

我正在尽力使Open SaaS尽可能专业、功能齐全,但又不会使其过于复杂,并以通俗易懂的方式分享我在此过程中的学习心得。如果您发现任何关于此过程的困惑,请在评论中告诉我们,我们会尽力为您解答。

另外,如果您喜欢我们在这里所做的工作,无论是通过文章还是Open SaaS,请告诉我们,并考虑在 GitHub 上给我们一个 star!这有助于激励我们,并为您带来更多类似的东西。

谢谢,下篇文章再见。

文章来源:https://dev.to/wasp/ive-been-writing-typescript-without-understanding-it-pt-2-17af
PREV
🥇首个让你可视化 React/NodeJS 应用的框架 🤯 奖品可视化,名字叫 Wasp Studio 这是派对恶作剧吗!? 一张图片胜过千言万语 帮助计算机帮助我们
NEXT
“Vibe Coding” 全栈应用的结构化工作流程步骤 5:闭环 - AI 辅助文档