David Herron Software Engineer and author (Node.js Web Development and more) passionate about Node.js, climate change, EV’s, and clean energy. https://sourcerer.io/robogeek

Is TypeScript on Node.js good enough for Java developers?

17 min read 4829

Every now and then you run into a cranky programmer who hates Javascript. They argue that JavaScript is terrible because it lacks type checking or any strict anything. They’ll insist that enterprise-grade systems require a certain degree of rigor that can only be found in strictly typed languages (like Java or C# or C++).

The argument continues. In a “small” application, anonymous objects with loosely typed fields are okay. But surely a system with millions of lines of code can only really work if the compiler and runtime system help programmers find bugs. Only a language with strong typing and other features can prevent certain classes of programming errors.

Assuming you’ll never convince the crankiest, most grizzled programmers that JavaScript isn’t the worst, is it possible to at least propose a middle ground solution?

Maybe. Enter Typescript.

In this article, I’ll evaluate Typescript from the point of view of an experienced Java programmer who has embraced JavaScript, Node.js, and Vue.js, etc. in a big way. I’m curious just how much Typescript can do to improve my ability to code in JavaScript.

Tooling and setup

The Typescript toolchain is written in Node.js. Of course, your first step is to install Node.js and npm. We will be using Node.js version 10.x in this article (10.12 is the latest release as of this writing) because of its support for ES6 modules.

You will learn from the Typescript quick start guide that one installs typescript like this:

$ npm install -g typescript

The Typescript package is recommended to be installed globally (the -g option). It installs a command, tsc, that is the Typescript compiler. The compilers purpose is generating JavaScript source from Typescript files. The JavaScript is what will be executed and is what you should deploy to browsers or as Node.js modules.

Now, you can type this to see the usage instructions:

$ tsc — help

Another highly useful tool is ts-node, a variant of the node command that directly executes typescript source.

It’s installed like this:

$ npm install ts-node -g

Once installed a command, ts-node, is available.

The next bit is to set up a Node.js project in order to follow the examples in this article. First, make a blank directory, then run npm init to set up a blank npm/Node.js project.

In the same directory create a typescript configuration file, tsconfig.json, which can contain this:

{
 “compilerOptions”: {
 “lib”: [ “es5”, “es6”, “es7”,
 “es2015”, “es2016”, “es2017”, “es2018”, “esnext” ],
 “target”: “es6”,
 “moduleResolution”: “node”
 }
}

This says to compile against the ES5/ES6/etc specifications, which is what Node.js 10.x implements. It outputs code using the ES6 specification, again that’s what is available in Node.js 10.x.

You can find more about this in the Typescript documentation.

The last thing to set up is specific support for Node.js in Typescript.

We will add the DefinitelyTyped collection of Typescript — a huge collection of types for specific libraries or platforms in the JavaScript ecosystem.

Typescript includes a capability to implement a Declaration File. That’s what the DefinitelyTyped project does, creates a well-specified declaration file. See the repository for more information but be prepared to be disappointed by the lack of useful documentation.

Adding the DefinitelyTyped definitions for Node.js brings in support for certain Node.js features. We’re doing this to head off a specific problem we’d otherwise have with the process object.

There is a difference between what Node.js does for its traditional modules (based on the CommonJS modules spec) and what it does for ES6 modules. In traditional Node.js modules, several objects are injected like module and process. Those objects are not part of the ES6 module specification and are therefore not available in ES6 modules.

Since Typescript uses ES6 modules those objects are missing, preventing us from using them. In a script we’ll run later we need to get arguments off the command line, which of course uses the process object.

The solution is to install the package @types/node. This package is part of the DefinitivelyTyped collection and provides definitions for Node.js. All that’s required is installing the package as a development dependency:

$ npm install — save-dev @types/node

Quick example

Let’s start a variant of the quick start tutorial. Create a file, name it greeter.ts (note the “.ts” extension) containing:

function greeter(person: string) {
 return “Hello, “ + person;
}
let user = “Jane User”;
// let user = [0, 1, 2];
console.log(greeter(user));

Then execute it as so:

$ ts-node greeter.ts
Hello, Jane User

With the ts-node command we don’t need to set up anything, it just runs the code. Of course, that won’t work for production, for which purpose we must compile the Typescript to JavaScript.

Compiles are done like this:

$ tsc greeter.ts 
$ cat greeter.js 
function greeter(person) {
 return “Hello, “ + person;
}
var user = “Jane User”;
// let user = [0, 1, 2];
console.log(greeter(user));

The Typescript source is straightforward JavaScript except for the parameter list of the greeter function.

function greeter(person: string) { … }

This is where Typescript starts to help us out. The parameter, person, is declared with a type, string. In regular JavaScript, we have no assistance from the compiler to avoid problems with the parameter passed to this function. A caller could pass anything and in JavaScript, it doesn’t matter. But what if our function correctly executes only with a String?

In traditional JavaScript, we would manually check the type like this:

if (typeof greeter !== “string”) throw new Error(“bad type for person”);

Writing our code like this would be more robust, but most of us don’t bother. In the classic book The Elements of Programming Style, by Kernighan and Plauger, the authors strongly recommend using defensive coding. That is, to check function parameters before assuming what they are because otherwise, the program might crash.

That’s where languages with strong type checking come in. The compiler (and runtime) step in to do the things most of us don’t bother to do.

In the example code, you’ll see two variants of the user object, one of which is an array. Change the source code to this:

// let user = “Jane User”;
let user = [0, 1, 2];

With this, we’ve introduced a problem. The user object is an array and does not match the function parameter list:

$ ts-node greeter.ts 
/Volumes/Extra/logrocket/typescript/start/node_modules/ts-node/src/index.ts:261
 return new TSError(diagnosticText, diagnosticCodes)
 ^
TSError: ⨯ Unable to compile TypeScript:
greeter.ts(8,21): error TS2345: Argument of type ‘number[]’ is not assignable to parameter of type ‘string’.

This is excellent. Compile-time error checking, in JavaScript, warning us about a problem. Because, yes, we do have an error here. Even better, the error is clearly described and we can understand what to do.

With this, we start to see a “win” shaping up. With strict type checking language it’s looking like we have a compiler behind us to double check that we haven’t committed a coding problem.

Typescript interfaces and a bigger example

Typescript has a whole panoply of interesting features similar to the Java or C# languages. For example, it has a class concept that’s a superset of what was defined in ES-2015/6, with the addition of types of course. But going over the documentation one feature that stands out is their take on interfaces.

In Java and other languages, interface objects are a key to flexibility. An interface is not a full-fledged class. Instead, it is an attribute which can be applied to classes. For example in Java the java.util.List interface is implemented by several concrete classes like ArrayList, LinkedList, Stack and Vector. You can pass any of these List implementations to any method declared to accept a List, and the method does not care about the concrete implementation.

In old-school JavaScript, we had the idea of duck typing to fulfill the same concept. This idea is that if it quacks like a duck it must be a duck. In an anonymous JavaScript object, if the object has a field named quack the object is expected to describe ducks, with different attributes for different duck species.

The Typescript Interface feature is duck typing backed up by Typescript language syntax. One declares an interface InterfaceName { .. fields }, and then the InterfaceName can be used as a type in method parameters or fields in objects. During compilation the Typescript compiler, while doing its static code analysis, will check whether objects conform to any interface declared on each parameter or field.

To try a simple example, create a file and name it interface1.ts containing this:

enum Gender {
 male = “male”, female = “female”
}
interface Student {
 id: number;
 name: string;
 entered: number;
 grade: number;
 gender: Gender
};
for (let student of [
 {
 id: 1, name: “John Brown”, 
 entered: 1997, grade: 4,
 gender: Gender.male
 },
 /* {
 id: “1”, name: “John Brown”, 
 entered: 1997, grade: 4,
 gender: Gender.male
 }, 
 {
 id: 1, name: “John Brown”, 
 entered: 1997, grade: 4,
 gender: “male”
 } */
]) {
 printStudent(student);
}
function printStudent(student: Student) {
 console.log(`${student.id} ${student.name} entered: ${student.entered} grade: ${student.grade} gender: ${student.gender}`);
}

What we’ve done is define an interface and a few anonymous objects. The anonymous objects haven’t been declared to implement the student interface, they’re just objects. But, these anonymous objects are in a loop that passes the objects to printStudent calls. Using static code analysis the Typescript compiler sees that each object must conform to the student interface.

When Typescript matches an object against an interface, it goes field-by-field through the interface definition matching against the fields in the supplied object. For the object to be considered as implementing the interface, it must have all of the matching fields and the types must match up. You can find out more in the documentation.

Run the example shown above, and you get this:

$ ts-node interface1.ts
1 John Brown entered: 1997 grade: 4 gender: male

Consider the possibility of an incorrectly structured object that doesn’t match the student interface. The commented-out entries in this array are meant to demonstrate that possibility.

Uncomment those two entries in the array and you instead get this:

$ ts-node interface1.ts
/Volumes/Extra/logrocket/typescript/start/node_modules/ts-node/src/index.ts:261
return new TSError(diagnosticText, diagnosticCodes)
^
TSError: ⨯ Unable to compile TypeScript:
interface1.ts(31,18): error TS2345: Argument of type ‘{ id: string; name: string; entered: number; grade: number; gender: Gender; } | { id: number; name: string; entered: number; grade: number; gender: string; }’ is not assignable to parameter of type ‘Student’.
Type ‘{ id: string; name: string; entered: number; grade: number; gender: Gender; }’ is not assignable to type ‘Student’.
Types of property ‘id’ are incompatible.
Type ‘string’ is not assignable to type ‘number’.

Again, we have successfully detected a commonplace problem — passing incorrectly structured objects to a function. The second element of the array — the id field — uses a string rather than a number value, resulting in the error here. In the third element of the array, the gender field uses a simple string rather than Gender.male or Gender.female.

Another win. But in the next section, we’ll look at ways Typescript fails us.

Retrieving from external storage — execution-time type checking

Our example was simple but contained a significant problem. Data is stored in an array when it should be in external storage. Obviously, a student registry system must have data stored in a reliable location rather than statically listed in the source code. Let’s fix this problem.

As we fix this problem we open up a can of worms. Since Typescript only does compile-time type checking it does not help us catch problems during execution. This is a disadvantage compared to languages like Java or C# where type checking is performed at runtime. Along the way, we’ll learn enough about Typescript to render judgement in the conclusion.

We’ll use a YAML file for external data storage while building on the previous example. Create a new file, interface2.ts, containing this:

import * as yaml from ‘js-yaml’;
import { promises as fs } from ‘fs’;
import * as util from ‘util’;
class Registry {
 private _yaml: string;
 private _parsed: any;
async load(fn: string): Promise<void> {
   this._yaml = await fs.readFile(fn, ‘utf8’);
   this._parsed = yaml.safeLoad(this._yaml);
 }
get students(): Student[] {
   if (this._parsed) {
     let ret: Student[] = [];
     for (let student of this._parsed.students) {
       try {
         ret.push({
           id: student.id,
           name: student.name,
           entered: student.entered,
           grade: student.grade,
           gender: student.gender
         });
       } catch (e) {
         console.error(`Could not convert student ${util.inspect(student)} because ${e}`);
       }
    }
    return ret;
  }
 }
}
let registry: Registry = new Registry();
let fn = process.argv[2];
registry.load(fn)
.then(res => { listStudents(); })
.catch(err => { console.error(err); });
async function listStudents(): Promise<void> {
 for (let student of registry.students) {
   printStudent(student);
 }
}
function printStudent(student: Student): void {
 console.log(`${student.id} ${student.name} entered: ${student.entered} grade: ${student.grade} gender: ${student.gender}`);
}
enum Gender {
 male = “male”, female = “female”
}
interface Student {
 id: number;
 name: string;
 entered: number;
 grade: number;
 gender: Gender
};

Primarily we’ve added a registry class that handles the retrieving of student data from the YAML file. For now, the only data it will support is an array of student records. Obviously, other data items could be stored in the YAML file for a more complete application. The getter named students will access the array of student information records.

Next, create a file, students.yaml, containing this data:

students:
 — id: 1
   name: John Brown
   entered: 1977
   grade: 4
   gender: male
 — id: “2”
   name: Juanette Brown
   entered: 1977
   grade: “4”
   gender: female
 — id: 3
   name: Nobody
   entered: yesterday
   grade: lines
   gender: None-of-your-Business

In YAML-ese this is an array named students and it contains fields which happen to match the student interface. Except as we’ll see, none of the data precisely matches the student interface. The third has values obviously at huge variance from the student interface.

In the registry class we have a function load that reads the YAML text, then parses it to an object. The data is stored in private members of the class.

Typescript class definitions are a superset of the class object introduced with ES6. One of the additions is the private and protected keywords that create a measure of information hiding. We can store this local data in the object instance, and have some assurance other code won’t access that data.

In the middle of the script you’ll see we instantiate a registry, then call registry.load followed by listStudents which steps through and prints the list of students.

In registry.load we were fairly explicit with the type declarations. The fn parameter (file name) is declared to be a string and the function is declared to return nothing. Because load is an async function, Typescript forces us to declare it as Promise<void> since async functions always return a promise. This syntax means a Promise that resolves to void. This syntax looks like the generics feature of other languages (which is the intent).

In Typescript the syntax for Array of Foo objects is Foo[]. Hence, the students accessor method is declared to return an array of student objects.

To fill in the array of student objects, we create simple objects from the data in the YAML file. It so happens the fields in our YAML file match what’s defined in the student interface, so this should work fine (knock on wood).

To bring in YAML support:

$ npm install js-yaml — save

The program is executed like this:

$ ts-node interface2.ts students.yaml 
(node:9798) ExperimentalWarning: The fs.promises API is experimental
1 John Brown entered: 1977 grade: 4 gender: male
2 Juanette Brown entered: 1977 grade: 4 gender: female
3 Nobody entered: yesterday grade: lines gender: None-of-your-Business

That first line, about fs.promises, is a byproduct of using the fs Promises API. Don’t worry about it, as we’re using it to simplify the coding.

The data file has three entries, and we are shown three outputs with no errors. Cool, it works, nothing more to do, right?

Wrong. The problem is all of these items should have failed because the data types did not match the student interface. For the second and third entries, several fields are strings when they should have been numbers, and therefore do not match the type in the student interface. In no case does the gender field contain a Gender enum, instead, it always contains a string.

The issue is the type checking in the printStudent function only occurs at compile time, not at execution time. This is easy to see yourself. Simply run this to compile the code:

$ tsc

With the configuration already shown, this compiles the Typescript files to JavaScript using the target configured in tsconfig.json. The compiled JavaScript is what’s actually executed, so looking at this code is helpful when trying to understand why your program doesn’t behave as expected.

In the compiled code, interface2.js, you’ll see this is the printStudent function:

function printStudent(student) {
  console.log(`${student.id} ${student.name} entered: ${student.entered} grade: ${student.grade} gender: ${student.gender}`);
}

This is a clean straightforward function, but do you see any type checking? Nope. Nor do you see any in the rest of the compiled code. Again, Typescript’s excellent type checking only occurs during compilation, not during execution.

We were foolish to think we could read an array and directly use it as student objects. The students getter should be written defensively, and to examine the objects we receive to verify they match the student interface declaration and map them into a corresponding object instance. Let’s see how to do this in Typescript.

If you’re keeping score, the wins we experienced in the previous two sections are now tarnished. To get full type checking we have to implement execution-time verification ourselves.

Execution-time type checking in Typescript

The identified primary issue now is the lack of type checking during execution. The students array in our data file could contain anything, and our code will pass it along as if it’s correct when it isn’t. Defensive programming says we should clean up, a.k.a. normalize the data before using it.

To normalize the data our code must handle these cases:

  • All fields exist and are correctly formatted
  • The gender field must be checked for all correct gender values
  • The numerical fields must accommodate either number or string values, and store the field as a number
  • It must detect fields that have completely bonkers values
  • It must detect missing fields

Copy interface2.ts to be interface3.ts and get ready to make changes.

Let’s start by creating a class StudentImpl to implement the Student interface. Does this reek of “former Java programmer” to name a class StudentImpl? What an ugly class name, but it is common practice in Java.

If we simply used this:

class StudentImpl implements Student {
 id: number;
 name: string;
 entered: number;
 grade: number;
 gender: Gender;
};

We will not have gained anything because there is no run-time enforcement of anything.

In the Typescript documentation, it is recommended that for a case like this the data is stored in a private field, and get/set accessor functions are used.

Now, the class definition would be:

class StudentImpl implements Student {
 private _id: number;
 private _name: string;
 private _entered: number;
 private _grade: number;
 private _gender: Gender;
get id(): number { return this._id; }
 set id(id: number) { this._id = id; }
 .. similar pattern for the other fields
};

But this does not account for the following:

  • The case where the YAML used a string rather than a number
  • A badly formatted number
  • A missing field

After quite a bit of experimentation we developed this class definition:

class StudentImpl implements Student {
 constructor(id: number | string, 
             name: string, 
             entered: number | string,
             grade: number | string, 
             gender: string) {
   this.setID(id);
   this.setName(name);
   this.setEntered(entered);
   this.setGrade(grade);
   this.setGender(gender);
 }
 private _id: number;
 private _name: string;
 private _entered: number;
 private _grade: number;
 private _gender: Gender;
get id(): number { return this._id; }
 set id(id: number) { this.setID(id); }
 setID(id: number | string) {
   this._id = normalizeNumber(id, ‘Bad ID’);
 }
 get name() { return this._name; }
 set name(name: string) { this.setName(name); }
 setName(name: string) {
   if (typeof name !== ‘string’) {
     throw new Error(`Bad name: ${util.inspect(name)}`);
   }
   this._name = name; 
 }
get entered(): number { return this._entered; }
 set entered(entered: number) { this.setEntered(entered); }
 setEntered(entered: number | string) {
   this._entered = normalizeNumber(entered, ‘Bad year entered’); 
 }
get grade(): number { return this._grade; }
 set grade(grade: number) { this.setGrade(grade); }
 setGrade(grade: number | string) {
   this._grade = normalizeNumber(grade, ‘Bad grade’);
 }
get gender(): Gender { return this._gender; }
 set gender(gender: Gender) { this.setGender(gender); }
 setGender(gender: string | Gender) {
   this._gender = parseGender(gender);
 }
}

In this case, the pattern for each field is:

  • Declare data storage as a private field in the object definition
  • Declare a simple getter function to access that field
  • Declare a simple setter function that calls setFieldName
  • Declare a function named setFieldName which validates the data before storing it in the field

You should notice an oddity with the parameter type in the setFieldName methods. Hang on, we’ll get to that.

We also have a constructor that will assist in creating object instances. To use the constructor, in the registry class change the students getter to this:

get students(): Student[] {
 if (this._parsed) {
   let ret: Student[] = [];
   for (let student of this._parsed.students) {
     try {
ret.push(new StudentImpl(
         student.id, student.name, 
         student.entered, student.grade, 
         student.gender));
     } catch (e) {
       console.error(`Could not convert student ${util.inspect(student)} because ${e}`);
     }
   }
   return ret;
 }
}

In other words, rather than push an anonymous object into the array we push a StudentImpl.

Let’s now talk about the parameter to the setFieldName methods:

setFieldName(grade: number | string) { .. }

This is a Typescript feature called Union Types. Where the code says “grade: number | string” you should read this as saying “the parameter grade can have either type number or type string”.

In the vast majority of languages, each variable is declared with one type, while Typescript allows variables to have two or more types. This may seem very strange at first, but it is extremely useful. To make it even more fun and different another Typescript feature, Intersection Types, lets you declare a variable type to be the combination of every type listed.

In this application, we have a data file where these fields can easily be either a number or string. In the issues listed earlier, we said the number fields must be initializable from either a number or string value and be stored as a number. The parameter type definition (union type) is the first step to implementing that goal. The second step is the normalizeNumber function, which we’ll see in a moment, which must also use a Union Type and handle conversion from either to number while doing type checking to ensure correctness.

Ideally, the “set” accessor would have been sufficient and we would not have required this third function. But the Typescript compiler did not allow that to be, and therefore we had to introduce this third function. But do we have to remember to always call this third function?

We have been a little bit sneaky. Each setter goes ahead and calls the corresponding setFieldName function. Inspecting the compiled code we see that because there is no compile-time type checking the setter will end up doing the right thing:

get entered() { return this._entered; }
set entered(entered) { this.setEntered(entered); }
setEntered(entered) {
 this._entered = normalizeNumber(entered, ‘Bad year entered’);
}

As we already know, at execution time the JavaScript code does not enforce (as we see here) the types written in the Typescript code. Therefore no matter what type we supplied to the setter, it will be passed through to the corresponding setFieldName function and the execution-time type checking will execute providing the safety we sought.

We’ve been remiss in not looking at the required functions for execution-time type checking and conversion.

function normalizeNumber(num: number | string,
                         errorIfNotNumber: string): number {
  if (typeof num === ‘undefined’) {
    throw new Error(`${errorIfNotNumber} — ${num}`);
  }
  if (typeof num === ‘number’) return num;
  let ret = parseInt(num);
  if (isNaN(ret)) {
    throw new Error(`${errorIfNotNumber} ${ret} — ${num}`);
  }
  return ret;
}
function isGender(gender: any): gender is Gender {
 return typeof gender === ‘string’
    && (gender === ‘male’ || gender === ‘female’);
}
function parseGender(gender: string): Gender {
 if (!isGender(gender)) throw new Error(`Bad gender: ${gender}`);
 return (gender === ‘male’) ? Gender.male : Gender.female;
}

In normalizeNumber we make various checks and either return a number or else throw an error. It relies on behavior of the parseInt function where if it cannot find a parseable number in the input it simply returns NaN. By checking for NaN we’ve automatically detected a whole range of possible error conditions.

Likewise in parseGender we make various checks and either return the gender or throw an error.

The key technique here is what Typescript calls a type guard. These are runtime expressions that guarantee the type of a variable is what’s expected. A type guard for the gender enum is the isGender function shown in the code. The return type in isGender, “foo is Type”, is a boolean value, true or false, indicating whether the named parameter matches that type.

In the interpolateNumber function we have an inline type guard:

if (typeof num === ‘number’) return num;

Elsewhere in the Typescript documentation, it is said the compiler will recognize this pattern as a type guard. It will recognize both typeof and instanceof expressions for this purpose.

Type guards work hand-in-hand with the type inference performed by the Typescript compiler. The compiler performs extensive static code analysis during compilation. When it recognizes this pattern it can deduce the allowable types for each variable.

But this is not unique to Typescript. Strip away the types and you have normal JavaScript code of the sort you’d use for defensive programming. The type guards are simply a way of implementing the sort of runtime type checking we should be doing anyway. As we noted earlier, most of us do not write code defensively like this. Plausibly we will gain enough value from having written code the Typescript way, type guards and all, that we will be incentivized to actually implement the type guards.

We now get this:

$ ts-node interface3.ts students.yaml 
(node:10048) ExperimentalWarning: The fs.promises API is experimental
Could not convert student { id: 3,
 name: ‘Nobody’,
 entered: ‘yesterday’,
 grade: ‘lines’,
 gender: ‘None-of-your-Business’ } because Error: Bad year entered NaN — yesterday
1 John Brown entered: 1977 grade: 4 gender: male
2 Juanette Brown entered: 1977 grade: 4 gender: female

We have our runtime type checking. The code has type definitions in every corner which not only help the Typescript compiler but helps future maintainers of this code in knowing what’s what.

The result currently is that our code is able to normalize the two student records (which could be normalized), and it notes an error in the third because it cannot be normalized.

To get this result we had to implement our own execution-time type checking. Typescript does not help us in this area, but we should have used defensive coding practices anyway.

Conclusion

While we’ve only touched the surface of Typescript, we’ve seen enough to evaluate its usefulness. Will a Java or C# programmer be comfortable enough to write a large system?

As long as the coder understands its limits Typescript is a very useful programming language.

In every area, Typescript offers features beyond what is typically available in other languages. Type declarations are a superset of what other languages offer, as are class and interface declarations. Typescript’s class definitions are a superset of what was added to JavaScript in ES-2015. We didn’t touch on the module and namespace features, both of which are a superset of what’s available in regular JavaScript.

In other words, Typescript’s feature-set is beyond what folks are accustomed to in other languages or in JavaScript.

A Java or C# programmer will feel comfortable using Typescript classes to describe a class hierarchy with which to organize their code.

The major downside to Typescript is its type checking is only performed at compile time. As we saw there is no runtime type checking, and instead we have the overhead of coding that ourselves. Programmers using Java, C# or other languages do not have this overhead.

Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.

 

David Herron Software Engineer and author (Node.js Web Development and more) passionate about Node.js, climate change, EV’s, and clean energy. https://sourcerer.io/robogeek

Leave a Reply