扶我起来,我还能继续学!
大家好。我胡汉三又回来了。
是的,我仍然是个TypeScript小白,仍然在犯基础的 TypeScript 错误。
但是呢,我背后有同事啊,我那些聪明又好心的同事在我钻研 Open SaaS 的时候,慷慨大方地传授了我了很多非常棒的 TypeScript 技巧。
赠人玫瑰,手有余香。今天我要和大家分享的就是这些技巧。
在本系列《》一文中,我们一起学习了 小白也能写 TypeScript,我就是这么魔幻satisfies
关键字以及 TypeScript 的结构类型系统。
在今天的《后续》中,我们讲点更实际的,那就是,如何巧妙地在大型 app(如 SaaS app)中共享一组值,无论我们何时添加和更改新的值,都会随之更新 app 的其他部分。
用处非常之广,值得我们好好钻研。还是老规矩,我们边看代码边学。
跟踪大型app中的值
在 Open SaaS 中,如果我们想分配一些 payment plan 值,以便可以在整个 app,包括在前端和后端中使用,该怎么做呢?
很多时候,大多数 SaaS app 都会有一些不同的产品销售计划,例如:
每月的 Hobby
订阅计划每月的 Pro
订阅计划以及一次性付款产品,为用户提供 10 个积分( Credits
),积分可以在 app 中兑换。
那么,代码怎么写呢?聪明的我灵机一动,何不使用enum
,同时传递这些 plan 值来保持值的一致性呢?
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} />
})
...
)
}
Nice。看到了吗,我们在价格页面上将枚举用作 payment plan ID。然后将 ID 传递给按钮点击处理程序,根据请求发送到服务器,程序就知道应该处理哪个 payment plan 了。
// ./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');
}
//...
这里使用枚举的好处是,很容易保持整个 app 的一致性。
在上面的示例中,枚举用于将 price plan 映射到 price ID,price ID是我们在 Stripe 上创建产品时给出的,且已被保存为环境变量。
那么,问题来了,就当前这些代码,如果我们需要创建新的 plan,例如需要增加一个 50 积分的一次性付款产品,该怎么做呢?
export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10',
Credits50 = 'credits50'
}
看吧,我们必须翻遍整个 app 的代码,找到使用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
,该怎么办?
很大概率是,你一个稍不小心,就忘记了在某处引用新的 payment plan!直接完蛋!
这个时候,如果 TypeScript 能告诉我们哪里有忘记添加的地方,会不会超赞的?而这正是Record
类型可以帮助我们解决的问题。
怎么做呢,请继续往下看。
使用 Record 类型保持值同步
首先要说明的是,Record
是一种实用程序类型,可帮助我们输入对象。使用Record
可以准确定义键和值确切的类型。
在对象上这样写:Record<X, Y>
,表示“此对象文本必须为类型X的每个可能值定义一个类型Y的值”。换言之,Record
强制执行编译时检查的详尽性。
在实际操作的时候,这意味着当有人向枚举PaymentPlanId
添加新的值时,编译器不会让他忘记添加相应的映射。保证了对象映射的强大又安全。
哎哟喂,把自己都写感动了哇。
那么Record
是如何与PaymentPlanId
枚举协同合作的呢?首先我们来看看如何使用Record
类型来确保价格页面始终包含所有的 Payment Plans:
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
,值必须是包含 Payment Plan 信息的对象(PaymentPlanCard
)。
当我们向枚举添加新的值,例如Credits50
时,见证奇迹的时刻到了:
export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10',
Credits50 = 'credits50'
}
哎呀,TypeScript 给出了一个编译时错误,Property '[PaymentPlanId.Credits50]' is missing...
,我们就知道了价格页面不包含新 plan 的卡片。
体验了使用Record
来保持值的一致性之后,是不是感觉既简单又便捷?但是我们不应该只用在前端,还有服务器端函数呢,别忘了我们还需要这个函数来为我们处理不同的 Payment Plan 呢:
// ./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
//...
这技术真正酷的地方在于,通过Record
类型定义paymentPlans
,使用PaymentPlanId
枚举作为键值,我们就能始终确保不会遗漏任何 payment plan,也不会犯输错字母的错误。
伟大的 TypeScript 拯救了我们,赞美它。
此外,如果你对if else
块看不顺眼,切换为单行代码也是分分钟的事:
const priceId = paymentPlans[planId].stripePriceId
太干净了!哇哦:)
如果有需要,我们甚至可以在代码的其他地方使用paymentPlans
对象,使代码更简洁、更易于维护。感谢Record
类型,实现了一个真正的三赢局面。
映射优先Record,不要If else
为了进一步说明Record
的优势,让我们来看另一个在客户端使用它来显示用户帐户信息的示例。
首先总结一下 app 中我们做了哪些事情:
我们定义了 PaymentPlanId
枚举来归拢 payment plan ID,使它们在整个 app 中保持一致。我们在客户端和服务器代码中使用 Record
映射对象,确保所有的 payment plan 都存在于这些对象中。这样一来,如果我们后续需要添加新的 payment plan,就会收到 TypeScript 警告——新的 payment plan 也必须添加到这些对象中。
现在,我们在前端使用这些 ID ,将 ID传递给服务器端调用,以便在用户单击Buy Plan
按钮时正确处理付款。当用户完成付款时,程序将保存此PaymentPlanId
到数据库中用户模型的一个属性,例如user.paymentPlan
中。
感觉有点头大了?哈哈。仔细捋一捋,宝宝你可以的。
搞明白了?好的,那我们继续。让我们再次使用这个值以及包含Record
类型的映射对象,我们发现,比起使用if else
或switch
块,检索帐户信息明显更清晰、更类型安全了:
// ./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
枚举,使枚举包含所有的 payment plan 即可,伟大的 TypeScript 会警告我们不要有任何遗漏。
相比之下,如果我们使用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 else
或switch
语句更佳。具体情况总是应该具体对待,聪明的程序员肯定懂。
虽然俗话说,鱼与熊掌,不可兼得。但我一定要兼得,能不能行?能不能既有与Record
映射相同的类型检查彻底性,又具备if else
或switch
的优势?
试试使用 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;
}
上面的代码没有使用带Record
类型的映射,而是使用了一个相对简单的switch
语句,因为以此种方式分配subscriptionPlan
和numOfCreditsPurchased
这两个变量的值,更清晰、更易于阅读。
但是这样一来,我们就失去了Record
类型映射的详尽类型检查。换句话说,如果此时我们添加一个新的plan.kind
,例如metered-usage
,上面的switch
语句不会发送任何警告。
幸运的是,有一个简单的解决方案。我们可以创建一个实用程序函数来进行检查:
export function assertUnreachable(x: never): never {
throw Error('This code should be unreachable');
}
这可能看起来有点奇奇怪怪的,因为代码居然用到了never
类型。它的作用是告诉 TypeScript 此值应该“永远不会”出现。
为了让大家理解这个实用程序函数是如何工作的,现在让我们继续添加新的 plan 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'
Nice。我们在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
语句中处理复杂逻辑的好处,而且也确保了不会遗漏 app 中的所有plan.kind
。两好处尽在我手,可把我得意坏了。
这就是我的学习方式:发现问题,解决问题,再看看解决方案有没有能够改进的地方,从而不断进步,不断在潜移默化中增强能力。累石成山,集腋成裘,盖莫如此。
与诸君共勉!
继续 TypeScript 的故事
这是本系列的第 2 部分,第 1 部分是《小白也能写 TypeScript,我就是这么魔幻!》。TypeScript,真的是一个非常棒的语言,赞美她!
关注「前端新世界」加星标,我有讲不完的前端故事
↓↓↓
END
精彩推荐