Skip to Content
Course content

78: Converting a Small JS Library to TypeScript

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Most of the time, you aren't starting a project from a blank slate. You're inheriting a legacy codebase or, more likely, looking at a JS utility library you wrote two years ago and realizing you have no idea what the options object in initConfig() is actually supposed to contain. Converting existing JS to TS is a specific skill—it's more about archaeology than architecture.

To show you how I handle this, let's take a tiny "Shopping Cart" library I wrote in plain JavaScript. It's simple, but it has enough "magic" to make TypeScript grumpy if we don't handle it right.

The Messy Starting Point

Here is the original cart.js. It's functional, but notice how the items array is just... there. I'm assuming the items have an id and a price, but nothing enforces that. If someone passes a string for the price, the calculateTotal function will start concatenating strings instead of adding numbers. That's a classic JS nightmare.

// cart.js
let cart = [];

export function addItem(item) {
  cart.push(item);
}

export function calculateTotal(taxRate) {
  const subtotal = cart.reduce((acc, item) => acc + item.price, 0);
  return subtotal + (subtotal * taxRate);
}

export function clearCart() {
  cart = [];
}

My first move is always the same: rename the file to .ts. Don't try to rewrite the logic yet. Just change the extension and let the compiler scream at you. Once I did that here, TypeScript immediately flagged item.price inside the reducer as an error because cart is implicitly any[].

Defining What a "Product" Actually Is

I could just slap any on everything to make the red lines go away, but that defeats the whole purpose of this exercise. I need to define the "shape" of my data. Since a cart item is a fixed structure, an interface is the way to go.

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

let cart: CartItem[] = [];

export function addItem(item: CartItem) {
  cart.push(item);
}

Now the addItem function is guarded. If I try to push a random object, the build fails. This is where the real value starts to show up—I've just documented the requirements of the library using code instead of a README that will inevitably be out of date in three weeks.

Fighting with the Reducer (and Winning)

Now let's look at the total calculation. I tried to just add the type to the taxRate parameter, but I hit a snag. Here is where I made a mistake: I forgot that reduce can be finicky with type inference when the array starts empty.

I initially wrote this:

// My first (wrong) attempt
export function calculateTotal(taxRate: number) {
  const subtotal = cart.reduce((acc, item) => acc + item.price, 0); 
  return subtotal + (subtotal * taxRate);
}

Wait, why did that work? In this specific case, because I provided 0 as the initial value, TS inferred acc as a number. But as soon as I tried to make the accumulator more complex (like returning an object with subtotal and tax), TS lost the plot and started complaining that acc was any. I learned the hard way that when using reduce in TS, it's always safer to explicitly type the accumulator if the logic is anything more than basic addition.

Here is the refined, professional version:

export function calculateTotal(taxRate: number): number {
  const subtotal = cart.reduce<number>((acc, item) => {
    return acc + (item.price * item.quantity);
  }, 0);
  
  return subtotal + (subtotal * taxRate);
}

Note the <number> after reduce. That's a generic. I'm telling TypeScript: "The value this reducer produces will be a number." It's a small detail, but it prevents a whole category of "undefined is not a function" errors down the road.

Polishing the Public API

The final step is deciding how the outside world interacts with this. Right now, cart is a global variable in the module. That's a bit risky. I want to make sure that if I ever export the cart for viewing, it can't be modified directly from the outside.

I'll use the ReadonlyArray type. It's a great tool for library authors who want to say, "You can look, but you can't touch."

export function getCartItems(): ReadonlyArray<CartItem> {
  return cart;
}

Now, if a user of my library tries to do getCartItems().push(newItem), TypeScript will stop them. I've effectively turned a loose JS script into a predictable, type-safe module.




📋 Practical Task

Convert a Type-Safe Notification Dispatcher

You've been handed a small JavaScript utility called notifier.js. It handles sending messages to different channels (e.g., 'email', 'sms', 'slack'). Currently, it's prone to bugs because there's no validation on the channel names or the message structure.

Your goal: Convert this to TypeScript.

// notifier.js
const channels = {
  email: (msg) => console.log(`Sending Email: ${msg}`),
  sms: (msg) => console.log(`Sending SMS: ${msg}`),
  slack: (msg) => console.log(`Posting to Slack: ${msg}`)
};

export function sendNotification(channel, message) {
  if (channels[channel]) {
    channels[channel](message);
  } else {
    throw new Error("Invalid channel");
  }
}

Requirements:

  1. Create a NotificationChannel type (using a union of string literals) so that only 'email', 'sms', or 'slack' are permitted.
  2. Type the channels object so that it maps those specific channels to functions that accept a string and return nothing.
  3. Type the sendNotification function parameters and return type.
  4. Ensure that the channels[channel] lookup is type-safe and doesn't rely on any.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.