Sneh Pandya Exploring the horizon with a knack for product management. Co-host of the NinjaTalks podcast and community organizer at Google Developers Group. Explorer, adventurer, traveler.

Methods for TypeScript runtime type checking

9 min read 2640

Methods for TypeScript runtime type-checking

Code written in TypeScript is checked for errors before it is executed, during compile time. In this article, we’re going to show type checking in TypeScript at runtime. First, we’ll run through a quick primer on this widely used JavaScript superset.

If you’re using JavaScript, you won’t have to worry about your code breaking because the program doesn’t regard type mismatches as errors until runtime. As a result of dynamic type checking, you no longer have to worry about fitting your whole program into a single type.

Scalability, on the other hand, becomes an issue. When you don’t have control over types, it becomes even more challenging to keep track of them, and the codebase loses its maintainability.

TypeScript handles this in a really clever manner: by using static type checking to restrict a variable’s type. Let’s have a look at two common myths about compile-time type checking (static type checking) and runtime type checking (dynamic type checking) below:

Myth 1: Type checking is equivalent to a type system

In a strongly-typed language, type errors occur when the types of variables in an expression do not match. Typed languages with high degrees of type safety, like TypeScript, are considered to be strongly-typed, which can result in explicit type errors, which terminate the program and force the developer to fix the bug.

Statically-typed languages such as Java are often thought of as being strongly-typed, which is true because of how precisely you can define data types during the initialization of a variable. An example is below:

String s = new String("Type checking!");

But, languages such as JavaScript, Ruby, etc., are dynamically-typed as well as strongly-typed, and you don’t need to declare the type during the initialization of a variable. An example is below:

  s = "Type checking!"

Type checking is used differently in each language, despite the fact that they are both strictly-typed.

Inferring a variable’s type based on its value is called type inferencing. As soon as type-inferring languages are mentioned, some programmers incorrectly assume that they are weakly-typed — which is not correct.

Myth 2: dynamic type checking is for interpreted languages

A majority of dynamically-typed languages are not compiled; rather, they’re interpreted languages. But this is not always the case.



Static and dynamic type checking each refer to the whole language as a single entity. Any version of a Java language is essentially statically-typed. This stands out from the other compiled or interpreted languages because of the language’s implementation rules. Thus, in concept, any given language can act as a compiled language or interpreted language.

Code written in Java is compiled in bytecode and has the JVM perform the interpretation of the generated bytecode. But there are also variants of Java that can directly be compiled to machine code or interpret the Java code as it is.

In JavaScript, dividing by zero returns infinity instead of a runtime error, in part because TypeScript never modifies the behavior of JavaScript code at runtime. The below example proves the same:

function divisionExample() {
    var x = 100 / 0;
    try {
        alert("Result is: " + x);
    } catch (e) {
        alert("Error is: " + e);
    }
}

This ensures that code moved from JavaScript to TypeScript will always execute correctly, even if TypeScript detects type issues.

But, what is there to do with the rules and type checking in TypeScript? Let’s find out!

The need for type checking in TypeScript

Moving code from a JavaScript file to a TypeScript file may cause type problems, sometimes because of coding issues and sometimes because TypeScript is being too cautious.

You may be wondering why further type checking is required — isn’t TypeScript all about type checking? In truth, the only time a static type is checked in TypeScript is during compilation. This is because the resulting JavaScript doesn’t know anything about the types you’ve created.

While this is great for internal code type checking, it does not guard against the kind of invalid input you may encounter externally. By default, TypeScript does not verify types at runtime in order to avoid runtime overhead and aggressively optimize runtime performance as a part of its design goals. Contextual information about types is removed during compilation because we compile to pure JavaScript, which doesn’t know the types at runtime because the type checking is not as strict in JavaScript as it is in other languages. You can make use of the typeof operator to detect the types, but it can be misleading at times.

This behavior does not provide any kind of safety mechanism against incorrect or invalid inputs, such as inputs from unhandled API endpoints. TypeScript is not designed to provide input constraints that are at an advanced level of type safety.

Strategies for TypeScript type checking at runtime

So, does TypeScript have any means to entertain the same? Absolutely!

In this section, we’ll cover the different ways to entertain different constraints with actual TypeScript types. Below are the supportive TypeScript mechanisms that make it possible:

  • TypeScript type guards: Type guards restrict the scope of variable types through conditional blocks and determine the type of variable to expect during code execution
  • Validation libraries: These libraries provide ready-made boilerplate code, methods, and, in some cases, interfaces to perform schema validation for your variable types
  • Transpilation: Takes the codebase written in a certain language and transforms into another language with the same level of abstraction rules
  • JSON schemas: The popular data interchange format for fast, easy, lightweight, and quick data transfers as well as conversion for the different entities to communicate
  • Manual checks: Manually maintaining the set of rules and applying it on a specific language to validate the types of data values — this is a cumbersome way of single-handedly managing rules with no other runtime type checking approach or library used, and eventually creates an unstable system

With these supportive mechanisms in place, it should be possible to achieve runtime type safety.

Using TypeScript type guards

Checking a specific value’s type at runtime is the primary function of type guards. This helps the TypeScript compiler, which then uses the information to become more predictive about the types.

Inbuilt type guards include instanceof and typeof. Have a look at each below:

class ClassOne {
    methodOne() {
        return true;
    }
}

class ClassTwo {
    methodTwo() {
        return true;
    }
}

function testType(instance: ClassOne | ClassTwo) {
    instance.methodOne(); // compiler will throw error
    instance.methodTwo(); // compiler will throw error

    if (instance instanceof ClassOne) {
        instance.methodOne();
    } else {
        instance.methodTwo();
    }
}

The above code snippet showcases the example of the type guard instanceof. There are two classes I’ve defined with brevity for the example. The function testType checks the instance of the classes created and outputs the result of the associated method of the class.


More great articles from LogRocket:


Different from the above, the type guard typeof is shown below:

function testType(data: string[] | string) {
    data.filter(() => false); // compiler will throw error
    data.split("Hello world!"); // compiler will throw error

    if (typeof data === "string") {
        return data.split("Hello world!");
    } else {
        return data.filter(() => false);
    }
}

The code snippet checks the type of the data used as input here using typeof. When the two different inputs here are put to the test, ultimately, the condition returns a value on the basis of type. The above code returns the output:

Hello world!

It is important for you to know that runtime checking must be at least as stringent as this if we want to maintain the promises provided by compile-time checking.

Using validation libraries

io-ts is a runtime type checking library that provides utility methods to perform data validation and encodes/decodes the data being parsed. io-ts is essentially used to streamline data operations in TypeScript for runtime type checking.

On the other hand, ts-runtime is also a library for performing runtime type checks for TypeScript, using the TypeScript Compiler API.

It is worth noting that, in contrast to ts-runtime, which produces runtime type checks on the basis of static ones, io-ts uses the opposite approach.

It is possible to use io-ts to provide runtime type checks that are very similar in appearance to those produced by ts-runtime, and the library actually enables TypeScript to infer the relevant static types on its own.

import x from "io-ts";

const CarType = x.type({
  model: x.string,
  company: x.string,
  seats: x.refinement(x.number, n => n >= 0, 'Positive')
})

interface Car extends t.TypeOf<typeof CarType> {}

The above code replaces the below interface declaration:

interface Car {
    model: string;
    company: string;
    seats: number;
}

This is an excellent method for dealing with interfaces. Static types infer runtime types because they remain consistent as your code changes. Additionally, the io-ts library supports recursive types and versatile definitions.

io-ts requires that your types are declared as io-ts runtime types, which is not possible with classes. To resolve this, you might create an interface using io-ts and then require the class to implement it.

However, if you add properties to your class, you must alter the io-ts type:

import * as t from "io-ts";
export const Car = t.interface({
    type: t.union([t.literal("model")]),    // altered type
    company: t.string,
seats: t.number
});

export const Cars  = t.array(Car);

The above interface alters the types of input data and returns the appropriate types.

You may have provided interfaces in your code as io-ts types, rather than standard TypeScript interfaces. While straightforward TypeScript interfaces can be shared between backend and frontend, io-ts types complicate this process.

Transpilation

The ts-runtime library processes your current TypeScript code somewhat like a type guard would, and yet it transpiles the source code into a similar code that has now runtime type checks.

Suppose you have the below TypeScript code:

interface Car {
    model: string;
    company: string;
    seats: number;
}

const test: Car = {
    model: "X",
    company: "Tesla",
    seats: 4
}

When using ts-runtime, the code is transpiled into the below version:

import x from "ts-runtime/lib";

const Car = x.type(
    "Car",
    x.object(
        x.property("model", x.string()),
        x.property("company", x.string()),
        x.property("seats", x.number())
    )
);

const test = x.ref(Car).assert({
    model: "X",
    company: "Tesla",
    seats: 4
});

One disadvantage of this technique is that there is no control over the places where the type checking takes place — every type check is turned into a runtime type check at the point of execution.

Runtime type checking is usually unnecessary because you only need to examine input structure at the borders of your program, not throughout it.

Manually generating JSON schemas

Using JSON schemas for runtime type checking has several benefits:

  • There are many available libraries that will verify input against a given schema
  • The JSON structure makes information simple to store and distribute

JSON schemas are a common means of imposing formatting restrictions on the JSON inputs you receive from other applications. This method is already being used by a number of non-TypeScript apps to verify incoming data.

Below is a simple JSON schema to validate incoming data for a native JavaScript project:

{
    "$schema": "http://json-schema.org/draft-07/schema#",
"title": "demo-app",
    "description": "A demo app using JavaScript",
    "properties": {
        "id": {
                    "description": "Demo object 1",
                    "type": "integer"
            },
            "name": {
                    "description": "Name of the object",
                    "type": "string"
            },
            "amount": {
                    "type": "number",
                    "minimum": 0
            }
        },
    "required": ["id", "name", "amount"]
}

JSON schemas have the disadvantage of being verbose and difficult to create manually. The following is an example of a very basic JSON schema input:

json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "required": [
    "model",
    "company",
    "seats"
  ],
  "properties": {
    "model": {
      "type": "string"
    },
    "company": {
      "type": "string"
    },
    "seats": {
      "type": "integer",
      "minimum": 1
    }
  }
}

Like the original TypeScript type, this JSON schema requires seats to be an integer with a minimum value of 1. This is OK, as long as our runtime type checking is at least as stringent as our static type checking.

But this doesn’t work if the runtime type checking is less stringent. For example, if our JSON schema enables the model to be anything other than a string, certain functionality may throw a mismatched type error when the code is executed. This would require a try-catch block to display and gracefully handle the error.

try {
    Return response(result);    // Normal code execution
} catch (e) {
    return err(e.message());    // 
}

Automatically generating JSON schemas

There are libraries that will produce JSON schemas for you based on the TypeScript code that you provide them, like typescript-json-schema. It may be used either programmatically or directly from the command prompt.

This library is meant to be used in conjunction with existing code that supplies the types for which the JSON schemas will be generated. However, changes to your code will need to be reflected in new JSON schemas, which means you must do it every time you make a change in order to generate the updated schema. This is repetitive, but without the updated schema, the runtime type checking would not be performed correctly.

As an alternative, there are tools available that can automatically infer JSON schemas from the JSON input that you provide. Of course, this does not make use of the type information that you have already established in your TypeScript code, and it may result in problems if the JSON input you supply to the tool does not match the real TypeScript type definitions.

{
    "$schema": "http://json-schema.org/draft-07/schema#",
"title": "demo-app",
“type”: “object”,
“properties”: {
    “id”: {
        “type”: “string”,
        “description”: “ID of the user”
    },
    “name”: {
        “type”: “string”,
        “description”: “Name of the user”
    },
    “birthdate”: {
        “type”: “string”,
        “description”: “Birth date of the user”
    }
},
“additionalProperties”: false,
“required”: [
    “id”,
    “name”,
    “birthdate”
]
}

The above example will generate the below output:

export interface DemoApp {
    id: string;
    name: string;
    birthdate: string
}

Suppose, if the input variable value for birthdate is DateFormat, such as 2022-01-01 instead of a string, or anything except the expected string input. Then, the input mapping will fail with the JSON schema and the code will throw an error.

Performing manual checks

Obviously, the most straightforward method in this case would be to manually construct a codebase that examines each input element for the presence of the necessary attributes and ensures that they are of the correct type.

Writing such code, on the other hand, may be time-consuming and as prone to error as anything else we write by hand. As changes are made to the codebase, there is also the chance that the error-checking code may go out of sync with your static types.

For example, suppose a team of three developers are working on a project and performing manual checks in the code to ensure runtime type safety. Initially, the developers would probably agree upon the checks developed, but as the codebase expands and more features are introduced, each of the developers would then be responsible for updating a set of functionalities, which would, again, be interdependent on the rest of the features of the project.

If Developer A writes the checks to keep the functionality or business logic at hand, a serious issue may arise because the rest of the app may not pass these checks partially or at all. Developers B and C may end up developing their own checks, thinking they are fixing a problem, but are instead making the codebase more complex and redundant.

This can result in a lot of things — code becoming stale, drifting away from project goals, costing more time and resources to overcome challenges of synchronicity. You may also run into issues maintaining or implementing further universal checks across the app that would ensure quality and error-free code.

If not maintained well, the checks may become obsolete, resulting in breaking changes, delays and issues across the app.

Conclusion

In this article, we covered four methods of type checking at runtime in TypeScript. One strategy may not be appropriate for all challenges, so you should carefully weigh your options for your distinct use-cases.

You can learn more about advanced types in TypeScript in the official documentation, as it is a powerful concept based on types.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Sneh Pandya Exploring the horizon with a knack for product management. Co-host of the NinjaTalks podcast and community organizer at Google Developers Group. Explorer, adventurer, traveler.

One Reply to “Methods for TypeScript runtime type checking”

  1. One big downside to io-ts and similar libraries, is all your types are inferred.

    This has some major drawbacks compared with literal types: there is nowhere for you to write documentation – or at least nothing that will be visible to IDE auto completion or a documentation generator. Also, error messages may be very difficult to understand – they aren’t going to point to a specific member, but instead to a complex stack of derived types. And lastly, no automated (remame) refactorings or “find usages” will be available, so you miss out on a lot of the productivity aspects of TS.

    I had high hopes for this approach, but I eventually ended up writing both validators and proper types anyhow, meaning a lot of duplication. Just something to consider before selecting this approach.

Leave a Reply