I've been writing TypeScript without understanding it -- pt. 2
vincanger
Posted on July 9, 2024
Give me a Break. I'm still learning!
Hey everyone. I'm back.
And, yeah, I'm still making n00b TypeScript mistakes đ˘
But luckily I've got some really clever coworkers that point out some awesome TypeScript tips while I continue to build Open SaaS and make it the best, free, open-source SaaS starter for React & NodeJS.
And I'm going to share those tips with you today.
In the first part of this series on TypeScript, I went into some fundamentals on what it is and how it works. I also touched on the satisfies
keyword and some of the quirks around TypeScript's structural typing system.
In this episode, I'm going to teach you how to share a set of values across a large app (such as a SaaS app) by using a nifty technique to make sure that you never forget to update other parts of your app whenever new values are added or changed.
Let's jump right into some code then.
Keeping track of values across a big app
In Open SaaS, we wanted to assign some payment plan values we could use across the entire app, both front-end and back-end. For example, most SaaS apps have a few different products plans they might sell, like:
- a monthly
Hobby
subscription plan, - a monthly
Pro
subscription plan, - and a one-time payment product that gives the user 10
Credits
they can redeem in the app (instead of a monthly plan).
So it seemed like a good idea to use an enum
and to pass those plan values around and keep them consistent:
export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10',
}
Then, we could use this enum on the Pricing Page, as well as in our server-side functions.
// ./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} />
})
...
)
}
Above, you can see how we use the enum as the payment plan ID on our Pricing Page. Then, we pass that ID to our button click handler, and send it in our request to the server so we know which payment plan to process.
// ./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');
}
//...
The nice thing about using the enum here is that itâs easy to use consistently across the entire app. And in the case above, we use it to map our pricing plans to the price IDs that they were given when we created these products on Stripe, which we've saved as environment variables.
But with our current code, what happens if we decide to create a new plan, such as a 50 Credit one-time payment plan, and add it to our app?
export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10',
Credits50 = 'credits50'
}
Well, currently, weâd have to go through the app, find each place where weâre using PaymentPlanID
, and add a reference to our new Credits50
plan.
// ./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');
}
Ok. That might not seem too difficult, but what if youâre using PaymentPlanId
in more than just two files? There is a really high chance you will forget to reference your new payment plan somewhere!
Wouldnât it be cool if we could have TypeScript tell us when we forgot to add it somewhere? This is exactly the problem the Record
type can help us solve.
Letâs take a look.
Using Record Types to Keep Values in Sync
First off, a Record
is a utility type to help us type objects. By using Record
we can define exactly what types our keys and values should be.
The type Record<X, Y>
on an object means "This object literal must define a value of type Y for every possible value of type X". In other words, Records enforce compile-time checks for exhaustiveness.
In practical terms, what this means is that when someone adds a new value to the enum PaymentPlanId
, the compiler won't let them forget about adding an appropriate mapping
This keeps our object mappings strong and safe.
Let's take a look at how it works with our PaymentPlanId
enum in action. Letâs first look at how we could use a Record
type to make sure we always have all the Payment Plans included on our pricing page:
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} />
})
...
)
}
Now planCards
is a Record
type where the key has to be a PaymentPlanId
, and the value must be an object with the payment plan information (PaymentPlanCard
).
The magic here happens when we add a new value to our enum, such as Credits50
:
export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10',
Credits50 = 'credits50'
}
Now TypeScript is giving us a compile-time error, Property '[PaymentPlanId.Credits50]' is missing...
, to let us know that our Pricing Page doesnât contain a card for our new plan.
Now you see the simple power of using a Record
to keep values consistent. But we shouldnât only do this for the front-end, letâs fix our server-side function that process payments for our different plans:
// ./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
//...
Whatâs really cool about this technique, is that by defining paymentPlans
with a Record
type that uses our PaymentPlanId
enum as the key value, we can always be sure we never forget any of our payment plans or make a silly typo. TypeScript will save us here.
Plus, we can switch out our entire if else
block for a clean one-liner:
const priceId = paymentPlans[planId].stripePriceId
Smoooooth :)
Itâs also very likely that we will use the paymentPlans
object elsewhere in our code, making it cleaner and much more maintainable. A real win-win-win situation, thanks to the Record
type.
Preferring Mappings with Record
over if else
Just to further bring the point home of how Record
can make our lives easier as developers, letâs look at another example of using it client-side to display some user account information.
First, letâs summarize whatâs going on in our app and how we used our friendly utility type already:
- we defined the
PaymentPlanId
enum to centralize our payment plan IDs and keep them consistent across the app. - we mapped objects using
Record
in the client and server code to make sure that all our Payment Plans are present in these objects, that way if we add a new payment plan we will get TypeScript warnings that they must also be added to these objects as well.
Now, we use those IDs on the front-end and pass them to our server-side call to process the payment for the correct plan when a user clicks the Buy Plan
button. When the user completes the payment, we save that PaymentPlanId
to a property on the user model in our database, e.g. user.paymentPlan
.
Letâs now take a look at how we can again use that value, along with objects mapped with the Record
type, to conditionally retrieve account information in a way thatâs much cleaner and more type-safe than if else
or switch
blocks can be:
// ./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>
)
}
Again, all we have to do is update our PaymentPlanId
enum to include any additional payment plans we may create, and TypeScript will warn us that we need to add it to all mappings where it was used as a Record
key or value type.
In comparison, if we were using an if else
block, weâd get no such warnings. Weâd also have no protection against silly typos, leading to buggier, harder to maintain code:
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>
)
}
But there are times when we need more complex condition checking and the ability to handle any side cases individually. In such cases, weâre definitely better off using if else
or switch
statements.
So how can we get the same type-checking thoroughness of Record
mappings, but with the benefits of an if else
or switch
?
By the wayâŚ
We're working hard at Wasp to create the best open-source React/NodeJS framework that allows you to move fast!
That's why we've got ready-to-use full-stack app templates with a simple CLI command, like Open SaaS, or a ToDo App with TypeScript. All you have to do is install Wasp:
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
and run:
wasp new -t saas
# or
wasp new -t todo-ts
You'll get a full-stack templates with Auth and end-to-end TypeSafety, out of the box, to help you learn TypeScript, or to get you started building your profitable side-project quickly and safely :)
Using never
⌠sometimes
The answer to the above question is that we need a way to check for âexhaustivenessâ in our switch
statements. Letâs use the example below:
// ./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;
}
Weâve reached for a relatively simple switch
statement here instead of a mapping with the Record
type because assigning the values of our two variables, subscriptionPlan
and numOfCreditsPurchased
, is a lot cleaner and easier to read this way.
But now weâve lost the exhaustive type checking weâd get with a Record
type mapping, so if we were to add a new plan.kind
, like metered-usage
for example, weâd get no warning from TypeScript in our switch
statement above.
Boo!
Luckily, there is an easy solution. We can create a utility function that will do the checking for us:
export function assertUnreachable(x: never): never {
throw Error('This code should be unreachable');
}
This might look weird, but whatâs important is the use of the never
type. It tells TypeScript that this value should âneverâ occur.
So that we can see how this utility function works, letâs go ahead now and add our new 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'
};
Now, if we add assertUnreachable
, check out what happens:
Ah ha! Weâre getting an error Argument of type '{ kind: "metered-usage"; }' is not assignable to parameter of type 'never'
Perfect. Weâve introduced exhaustive type checking into our switch
statement. This code is actually never meant to be run, itâs just there to provide friendly warnings for us in advance.
To get TypeScript to stop being mad at us in this case, all we have to do isâŚ:
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);
}
This is great. We get all the benefits of dealing with more complex logic in a switch
statement, with the assurance that we'll never forget any possible plan.kind
case being used in our app.
Stuff like this makes code way less error prone, and much easier to debug. A little bit of preparation goes a long way!
Continuing the TypeScript Tales
That was part 2 of this series, âIâve been using TypeScript without understanding itâ where I share my journey in learning the finer points of TypeScript from friends and colleagues as I build and maintain Open SaaS, an entirely free, open-source SaaS starter template.
Iâm trying my best to make Open SaaS as professional and full-featured as possible, without making it too complicated, and to share what I learn in the process in an easy-going way. If you find anything about this process confusing, let us know in the comments and weâll clarify the best we can.
Also, if you like what weâre doing here, either with the articles or with Open SaaS, please let us know and consider giving us a star on GitHub ! It helps to motivate us and bring you more of this stuff.
Thanks, and see you in the next article.
Posted on July 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.