DEV Community

Cover image for Using ArkType for TypeScript runtime validation
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Using ArkType for TypeScript runtime validation

Written by Yan Sun✏️

TypeScript provides robust features for type checking at compile time, but it doesn’t have inbuilt runtime type checking because the types don’t exist at runtime. As a result, runtime errors can occur due to unexpected input data.

That’s where ArkType comes into play. ArkType is a library that provides runtime validation for TypeScript interfaces and classes. It can catch errors caused by unexpected data at runtime, while still leveraging the static type system at compile time.

In this blog post, we’ll walk through how to use ArkType for runtime validation with TypeScript.

Jump ahead:

What is ArkType?

ArkType is a runtime validation library that can infer TypeScript definitions one-to-one and reuse them as highly-optimized validators for your data.

It is designed to behave as closely as possible to the TypeScript type system. With its rich definition syntax, developers can define types and benefit from the same level of flexibility and expressiveness offered by TypeScript. It helps avoid creating an unsatisfiable type, e.g. by intersecting two objects with incompatible properties.

Below is an example from the GitHub:

const data: {
    name: string;
    device: {
        platform: "android" | "ios";
        version?: number;
    };
} | undefined
Enter fullscreen mode Exit fullscreen mode

This example demonstrates the concept of the "one-to-one" validator, which means that the definition passed to the "type" function matches the inferred type. This is highly beneficial, as it allows us to define a type once and then use the inferred TypeScript type throughout the codebase, avoiding duplicative declaration.

Sometimes, the documentation references ArkType as being isomorphic. In the ArkType context, “isomorphic” means that the behavior at compile time and runtime will be identical. In other words, when a developer defines a type in their editor, they can expect to see the same outcome at runtime.

How ArkType works

Under the hood, the core of the library is a string parser that parses the type definition from a string, then converts the definition to a state object. This state object is then used for evaluation, validation, and type inference.

The parser has two identical implementations: static and dynamic. To ensure the two implementations generate the same result, the ArkType team developed a special test framework called “attest” (short for ArkType Test).

The attest framework is used to make assertions about compile-time types and runtime behavior simultaneously. This design ensures the “isomorphic” behavior between compile-time and runtime types.

The following example contains a typo in the definition. The type error shown in the VSCode screenshot reads "Bounded expression ‘boolean’ must be a number, string or array": A type error thrown in VS Code because of a typo in the definition Then, during runtime, the exact same error is thrown: The same type error thrown at runtime because of a typo in the definition In this particular case, the following test asserts that the function with a type error passed to “attest” will throw an error. The test ensures that both the type error and the error generated during runtime should contain an identical, expected message:

// dev/test/semantics.test.ts            
it("unboundable", () => {
    // @ts-expect-error
    attest(() => type("unknown<10")).throwsAndHasTypeError(
       writeUnboundableMessage("'unknown'")
    )
})
Enter fullscreen mode Exit fullscreen mode

Benefits of using ArkType

Thanks to its unique “isomorphic” parser, ArkType presents some distinct advantages:

  • No dependencies or plugins required in your editor
  • Concise type definitions
  • One-to-one definitions: ArkType definitions mirror TypeScript’s own syntax; usually, your definition will already look just like the type it will be inferred as
  • Clear error messages: Error messages are provided by default, which is especially useful for complex cases like unions
  • Robust type system: In addition to performing validation, ArkType actually contains a full type system under the hood, which means that it can not only check if a given value is allowed, but also check if any arbitrary type is assignable to another. This has lots of potential for advanced scenarios, i.e., automatically determining the best discriminators of discriminated unions
  • Cyclic types and data: ArkType can infer recursive and cyclic types using scopes

Setting up and using ArkType

To set up ArkType, we need to install it using npm (or another package manager):

npm install ArkType
Enter fullscreen mode Exit fullscreen mode

Once installed, we can import it to our TypeScript code like this:

import { type } from "ArkType"
Enter fullscreen mode Exit fullscreen mode

Let’s start with an example. Consider the following pkg type defined below, which requires an optional contributors property that contains two to 10 contributors:

export const pkg = type({
   name: "string",
   version: "semver",
   "contributors?": "1<email[]<=10"
})
Enter fullscreen mode Exit fullscreen mode

Then, we can use ArkType to validate that a given externalData object conforms to the pkg type at runtime:

// Get validated data or clear, customizable error messages.
const externalData = {
   name: "ArkType",
   version: "1.0.0-alpha",
   contributors: ["david@ArkType.io"]
};
export const { data, problems } = pkg(externalData)

// "contributors must be more than 1 items long (was 1)"
console.log(problems?.summary ?? data)
Enter fullscreen mode Exit fullscreen mode

Here, the returned CheckResult is destructed into data and problems. Since the externalData object only contains one contributor, a concise error message is shown with the returned problems.summary property: An error message due to an error with destructuring

ArkType type definitions

ArkType type definitions are rich and concise. The system is able to infer TypeScript definitions directly, and supports many of TypeScript’s inbuilt types and operators. The main features include:

  • Primitive and other basic types
  • Support for unions, discriminate unions, and expressions
  • Regex
  • Operators

ArkType support all TypeScript primitive types, including string, number, and boolean. The example below demonstrates the use of a number type definition:

const numberChecker = type("number");
const numberResult = numberChecker(123); // OK
const notNumberResult = numberChecker("notNumber");
// error: Must be a number (was string)
Enter fullscreen mode Exit fullscreen mode

The other basic types, including array, symbol, and tuple are also supported:

// Number Array
const numberArrayChecker = type("number[]");
const correctArray = numberArrayChecker([1,2,3]); // OK
const notNumberArray = numberArrayChecker([1,"test"]); // Item at index 1 must be a number (was string)

// Symbol
const symbolChecker = type("symbol");
const symbolResult = symbolChecker(Symbol("key")); // OK
const notSymbolResult = symbolChecker("key"); // Must be a symbol (was string)

// Tuple
const tupleChecker = type(["string", "number"]);
const correctTupleResult = tupleChecker(["hello", 10]); // OK
const wrongTupleResult = tupleChecker(["hello", "world"]); // Item at index 1 must be a number (was string)
Enter fullscreen mode Exit fullscreen mode

Union types can be defined as below:

type("'a'|'b'|'c'");
Enter fullscreen mode Exit fullscreen mode

ArkType also supports simple expressions, such as expressing a number range:

const rangeChecker = type({
   "powerLevel?": "1<=number<=100"
})
const withInRange = rangeChecker({powerLevel: 99}); // OK
const outsideRange = rangeChecker({powerLevel: 101});
// powerLevel must be at most 100 (was 101)
Enter fullscreen mode Exit fullscreen mode

The expression syntax is flexible, and accommodates string, number, and array data types. It allows the use of the "<", ">", "<=", ">=", and "==" comparators, and the limit within the expression must be a number literal:

type("string<=5") // length of string is less or equal to 5
type("number==-3.14159") // the value of number is equal to -3.14159
type("number%2") // the number is divisible with 2
Enter fullscreen mode Exit fullscreen mode

Another important ArkType feature is its support of regex, which makes the type definition more powerful. The following example showcases the definition of an object type with a name property that must start with an "ark" prefix using regex:

type({name: /^ark.*$/});
Enter fullscreen mode Exit fullscreen mode

It is worth noting that the regex will be inferred as a string by default, but used as regex in the runtime validation.

Support for TypeScript operators makes using ArkType much easier for TypeScript developers. One commonly used operator is the keyof operator, which extracts the key of properties:

const t = type(["keyof", { a: "123", b: "123" }]);
type keyOfT = typeof t.infer; // 'a' | 'b'
Enter fullscreen mode Exit fullscreen mode

The instanceof operator is another very useful operator:

class Ark {}
const arkChecker = type(["instanceof", Ark])
Enter fullscreen mode Exit fullscreen mode

The instanceof operator functions identically to the JavaScript instanceof operator, which verifies whether the prototype property of a constructor is present somewhere in the prototype chain of an object.

ArkType also provides several custom operators. One of them is narrow. The syntax for narrow is as below:

 ["type", "=>" , condition]
Enter fullscreen mode Exit fullscreen mode

It accepts a condition function, in which you can define custom logic to narrow the original type:

 const isOdd = (n: number) => n % 2 === 1
 const odd = type(["number", "=>", isOdd])
Enter fullscreen mode Exit fullscreen mode

The narrow operator can be useful when you want to create a reusable conditional validation rule against a specific type.

The other available custom operators include:

  • bound: allow data to be bounded in the format "S<N", or as a range: "N<S<N", with comparators restricted to < or <=
  • |>: also called the morph operator, used to chain the input type to a function

Using scopes in ArkType

In ArkType, scopes are collections of types that can reference each other. Scopes operate like a module system for ArkType types, and you can create multiple scopes or import one into another.

Here's an example:

const messageTypes = scope({
   info: {
       message: "string"
   },
   error: {
       id: "number",
       code: "string",
   }
}).compile();

const responseScope = scope({
   data: "string",
   exception: "error",
   message: "info"
},
{
   imports: [messageTypes]
}).compile();

responseScope.exception({id: 1, code: 'unhandled exeption'}); // OK
responseScope.exception({id: "1", code: 'unhandled exeption'}); //id must be a number (was string)
Enter fullscreen mode Exit fullscreen mode

In the example above, we define a scope called messageTypes, which contains two types: info and error. We then import this scope into the responseScope, which references the two type definitions for the exception and message types.

Scopes can be useful for reusing common types across multiple type definitions. ArkType also provides a set of inbuil subtypes that can be used within scopes. These subtypes are inferred as strings, but they also provide additional validation at runtime.

For example, below, email is an out-of-box subtype that will be validated as a valid email address:

scope({
 email: "email",
...})
Enter fullscreen mode Exit fullscreen mode

A list of supported subtypes can be found in the Keywords section of the ArkType documentation.

Within a scope, ArkType has the ability to infer and validate cyclic and recursive types without any additional configuration. This means that if a type is defined in terms of itself, or another type that ultimately refers back to itself, ArkType can handle it.

The following example illustrates a data structure that contains circular references for the type package:

const recursiveScope = scope({
    package: {
        name: "string",
        "dependencies?": "package[]",
        "contributors?": "contributor[]"
    },
    contributor: {
        email: "email"
    }
}).compile();

const packageData = {
    name: "ArkType",
    dependencies: [{ name: "typescript" }],
    contributors: [{ email: "david@ArkType.io" }]
};
packageData.dependencies.push(packageData);

recursiveScope.package(packageData); // Ok
packageData.contributors[0].email = "ssalbdivad"; // update with invalid email
recursiveScope.package(packageData); // Error: contributors/0/email must be a valid email (was 'ssalbdivad')
Enter fullscreen mode Exit fullscreen mode

Automatically discriminating discriminated unions

Using its deeply computed internal representation of your types, ArkType automatically discriminates all unions by evaluating the most efficient set of properties to check. In other words, discriminated unions are pre-computed and a set of best “discriminants” are calculated. These pre-computed discriminants will greatly improve performance, especially for large discriminated unions.

Below is an example:

scope({
            rainForest: {
                climate: "'wet'",
                color: "'green'",
                isRainForest: "true"
            },
            desert: { climate: "'dry'", color: "'brown'", isDesert: "true" },
            sky: { climate: "'dry'", color: "'blue'", isSky: "true" },
            ocean: { climate: "'wet'", color: "'blue'", isOcean: "true" }
        })
Enter fullscreen mode Exit fullscreen mode

In the above example, the ArkType system automatically discriminates the union, and calculates the best key properties — these are color first, then climate.

This means the discrimination process is initiated by checking the color property; if it's brown, it knows it's on the desert branch. If it's green, it knows it's on the rainForest branch. If it's blue, it will discriminate a second time using the climate prop. If it's dry, it knows it's on the sky branch. If it's wet, it knows it's on the ocean branch.

All this is done without the user ever having to even know it's happening. But this will significantly speed up validation, especially as unions get larger.

Community and popularity

Released in February 2023, ArkType is a relatively new tool that is still in its early stages of development. However, the project is actively maintained and the author is quick to address any issues that arise.

It's important to note that as of now, ArkType is still in a 1.0-alpha release, meaning that some APIs may change before a stable 1.0 version is released. Despite this, the project has already gained a fair amount of attention, indicating a growing user base.

According to the author, there are a few upcoming improvements for ArkType:

  • Performance improvements: Currently, ArkType has similar performance to other validation libraries like Zod. The author is implementing a JIT compilation strategy that is expected to make ArkType 50-100 times faster
  • API documentation: work on the API documentation is currently in progress

Summary

In this blog post, we explored the fundamental concepts of using ArkType for runtime validation in TypeScript, covering both basic and advanced use cases. By leveraging ArkType, we can catch errors at runtime that TypeScript's type checking alone may not catch. This can lead to higher code quality and maintainability.

Although ArkType is still in its early stages, it has the potential to become a widely used validation tool. If you're starting a new project, it's worth considering trying out. However, bear in mind that some minor API changes may occur as ArkType approaches version 1.0.

To learn more about ArkType and its features, take a look at the official documentation.


LogRocket: Full visibility into your web and mobile apps

LogRocket Signup

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking usexrs 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 and mobile apps.

Try it for free.

Top comments (0)