I've been writing TypeScript without understanding it -- pt. 2

vincanger

vincanger

Posted on July 9, 2024

I've been writing TypeScript without understanding it -- pt. 2

Give me a Break. I'm still learning!

Hey everyone. I'm back.

And, yeah, I'm still making n00b TypeScript mistakes 😢

Image description

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.

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

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',
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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.

Image description



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

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

Image description

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

  //...


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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:

  1. we defined the PaymentPlanId enum to centralize our payment plan IDs and keep them consistent across the app.
  2. 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>
  )
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

and run:



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


Enter fullscreen mode Exit fullscreen mode

Image description

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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'
};


Enter fullscreen mode Exit fullscreen mode

Now, if we add assertUnreachable, check out what happens:

Image description

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


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
vincanger
vincanger

Posted on July 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related