Debugging TypeScript using Replay
Tags: typescript, debugging, opensource, node
Published: 2022-03-18 20:34:16

---
title: Debugging TypeScript using Replay
published: true
description: A real-world example of debugging an issue in TypeScript using Replay for Node.
tags: typescript, debugging, oss, node
//cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/imoyjanr6q5xlhgdki79.png
---

Recently, I sat down with Mateusz Burzyński, aka [AndaristRake](https://twitter.com/AndaristRake), to talk about how he debugged an issue in TypeScript using Replay for Node. With the [replay-node-cli](https://github.com/RecordReplay/replay-node-cli), you can record a Node runtime for debugging after the fact — no browser required.

![Screenshot of Github issue](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/imoyjanr6q5xlhgdki79.png)

## The bug

The bug, [described in the GitHub issue here](https://github.com/microsoft/TypeScript/issues/48160), happens when using [a newer feature in TypeScript](https://github.com/microsoft/TypeScript/pull/47190) that supports control flow analysis on dependent parameters contextually typed from a discriminated union type. 😅 There’s a lot to unpack there!

Take a look at the example below, and we’ll break it down.

```jsx
interface Foo {
  method(...args: ['a', number] | ['b', string]): void
};

const methodSyntax: Foo = {
  method(kind, payload) {
    if (kind === 'a') {
      payload.toFixed(); // error, Property 'toFixed' does not exist on type 'string | number'
    }
    if (kind === 'b') {
      payload.toUpperCase(); // error, Property 'toUpperCase' does not exist on type 'string | number'
    }
  }
}
```

Here we have a **union type**, which just means a combination of multiple types. The argument for our function can be `number | string`. A **discriminated union** means that there is a parameter that helps you distinguish (or *discriminate*) which type in the union applies (There’s [a great blog post here](https://css-tricks.com/typescript-discriminated-unions) on discriminated unions from CSS-Tricks).

In this case, we should be able to check `kind` and if it’s `a`, the type should be narrowed to `number`. Same goes for `b`, it should be narrowed to `string`. This is what is meant by “control flow analysis” — we can use an `if` statement or other logical check to narrow the type.

However, that’s not working in our example. Instead, the type is still `number | string` for each parameter.

## The debugging process

Mateusz walked us through how he investigated and ultimately identified the root cause of the bug using Replay. These steps can be used for any issue, and are a great example of how a developer debugs in the real world.

### Generate a reproducible example

This step was easy thanks to Replay. To get started debugging this issue, Mateusz recorded a small test of the following code. Using `replay-node-cli` he recorded the run to create [a debuggable replay of the bug](https://app.replay.io/recording/broken-dependent-param-method-variant--9ec573e1-1b77-4c3a-bc4b-beed7ad29db0).

```tsx
 type Foo = {
  method(...args:
    [type: "str", cb: (e: string) => void] |
    [type: "num", cb: (e: number) => void]
  ): void;
}

// this fails for some reason, as a method
let fooBad: Foo = {
  method(type, cb) {
    if (type == 'num') {
      cb(123) // error!
    } else {
      cb("abc") // error!
    }
  }
};
```

### Identify expected behavior

The next step in debugging is understanding what is *supposed* to happen when the application works as expected.

The original GitHub issue has [a playground reproduction that shows](https://www.typescriptlang.org/play?ts=4.6.2#code/JYOwLgpgTgZghgYwgAgGIHt3IN4FgBQyyAthGABboAmAFAHQNxQDmAzgFzIDaA5HDwBpkIAK7EARtAC6yAD7ce4wclZgooZlICUnAG7pgVAgF8A3AQIJ0IVSTKUqAZQCe4OAA9OGLAF4cBIlIKahoAa1AqIQAHOGcAG3Q4Ki1-QiJkYBhkMIjkH3zkPh4UvDT05Bj4xKo6MHRUYHcIWi1TZAB6duRoKHQoIQAFXqjoMGdCuoamqh5kKnQIVmF0MG73YFtrZDGRwtV1EGY5YTFJKB4A9ONLokzs8JAqPILFYtTyokqEpNr0AFUoiMoABhOCsCA0VodLo9PqDYajcY8OoAoGg8GzeaLZarCDrTYgbbOXY8fYaY6iCTQC5lZDXNL0+mWay2GAiEAIACi7iiUC8mDy7zswSonDZHLAwC2OUe0Vi32SQtuWRlT3yfiKJRu6S+1V+U2akKInWQ6FCsW19PKd1Vzw1Si1tM+8r1KMB0HREKhJrNFtpVrpJgs+GZNlWTF6AHdUOyEPzfEKgg5OKq5VUkikfAA+JUZFUPNUvfiOj4VF0-SaNQ3erq+5yW7U2gt2woO3PO9M1N1osFeto+831-2XRnBqxhkhwURwOIAEUWahECDAIgOzHjgtKgXsIQYdCYbBL6XHti4BbTCpkfgPrHMtKbuXVhWL7bLnf1VZa-drg4b9-zj4vG2W4fLqFb-O6IK9pC36mr+w4MiYQA) this feature works when using function declaration syntax and manual destructuring, but fails when using the method syntax.

Because the bug only occurs when using the method syntax, we can make an example of what *should* happen when this works properly. Mateusz [also recorded a replay here](https://app.replay.io/recording/correct-dependent-param-arrow-function-variant--c9d5b21a-5013-4ea2-85db-f831ba09f40b) of the working behavior when using an arrow function syntax. 

```tsx
type Foo = {
  method(...args:
    [type: "str", cb: (e: string) => void] |
    [type: "num", cb: (e: number) => void]
  ): void;
}
// suddenly works for arrow function
let fooOkay1: Foo = {
  method: (type, cb) => {
    if (type == 'num') {
      cb(123)
    } else {
      cb("abc")
    }
  }
};
```

Understanding what working code looks like is critical to debugging, because it allows you to identify what is *different* between the working and non-working code.

It also gives you a goalpost for debugging. Before you define your problem, you need to know what the application is expected to do in a given situation.

### Define the problem

We are now able to define the problem very specifically. Using a framework of “expected behavior” and “actual behavior” is a clear way to define the bug.

✅ **Expected behavior:** TypeScript should narrow the type of a discriminated union when using control flow analysis in a declared method.

🚫 **Actual behavior:** TypeScript does not narrow the type in declared method **only.**


Now that we know the problem, we can start investigating why it occurs.

### Isolate the issue

Next was understanding where in the code the error was actually happening. To do this, it’s important to understand **what code executed at the time the error occurred.** 

Replay helps with this by showing how many times a given line of code was hit during the recording. You can also lock in to a specific instance of that code’s execution to inspect your application values at that time. 

 Mateusz started with the `getDiscriminantPropertyAccess` function, which takes in `computedType` and `expr` parameters. From reviewing the code when this feature was added to TypeScript, he identified this function as related to the narrowing process.

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7urusf9rxswt3w86nzbd.png)

With the Replay console, you can view the value of properties at a given time during the code execution. Once the functions related to the bug have been isolated, the values of these parameters can be evaluated. This can be done with print statements or using the console.

Mateusz output `computedType` with the helper function `.__debugTypeToString()` to further evaluate.

The issue here is that the type should come back as either `string` or `number` depending on the variable, but instead both are showing a type of `string | number` which is causing the failure because of a type mismatch. 

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bh88d9s8l9qdk87xqi6h.png)

In the working example, however, the type evaluated correctly as `number`. 

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7qr26aruy7g3pkl9z6au.png)

### Tracing the root cause

Mateusz then used Replay’s stepping functionality to trace the execution of the code. By comparing the working example with the non-working example, he was able to identify lines that only executed in the working example. The lines after the declaration of `access` aren’t executed  when the bug occurs. We can see in the replay that these lines show `0 hits` on hover.

{% embed https://www.loom.com/share/01830047552347d086085e49888ac3b8 %}


Because `access` is being evaluated on line `79105` and failing the check, the next step is looking at `getCandidateDiscriminantPropertyAccess()` to see the return value.

We can see on line `79060` inside this function that `reference` is being evaluated to see if `ts.isBindingPattern()` or `ts.isFunctionExpressionOrArrowFunction()` is true. Because our syntax is *method* syntax and *not* a function expression or arrow function, the `reference` fails the check and the function does not continue.

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/66qorgab8969p8ykwjpw.png)

We can see this by printing the function to the console to see the return value.

**Broken version returns false, type is not narrowed**

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6sbg99d0tjktkhbo99g6.png)

**Working version returns true, type is narrowed**

![Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tasi0jfecuqxmwy3w0ig.png)

## The fix

We need to ensure that a function declared with the method syntax also evaluates true within the `getCandidateDiscriminantPropertyAccess()` function so it is narrowed appropriately.

[Here is the pull request](https://github.com/microsoft/TypeScript/pull/48110) with the suggested resolution by Mateusz, which adds an option for `isObjectLiteralMethod()` to the check to ensure that method syntax will also trigger the destructuring of the discriminant union. The fix was merged and this bug has officially been resolved! 🥳

👉 Want to record your own Node applications? Check out the [Replay Node guide here](https://docs.replay.io/docs/recording-node-js-0820cd9dacfe4c7ea78b25c8fae17ac4) and let us know at [replay.io/discord](https://replay.io/discord) if you have any questions!