Debugging TypeScript using Replay

ceceliacreates

Cecelia Martinez

Posted on March 18, 2022

Debugging TypeScript using Replay

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

Screenshot of Github issue

The bug

The bug, described in the GitHub issue here, happens when using a newer feature in TypeScript 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.

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'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

 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!
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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 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 of the working behavior when using an arrow function syntax.

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")
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

Screenshot

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.

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

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

Working version returns true, type is narrowed

Screenshot

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 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 and let us know at replay.io/discord if you have any questions!

💖 💪 🙅 🚩
ceceliacreates
Cecelia Martinez

Posted on March 18, 2022

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

Sign up to receive the latest update from our blog.

Related

Debugging TypeScript using Replay
typescript Debugging TypeScript using Replay

March 18, 2022