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?
- Setting up and using ArkType
- Using scopes in ArkType
- Automatically discriminating discriminated unions
- Community and popularity
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
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": Then, during runtime, the exact same error is thrown: 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'")
)
})
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
Once installed, we can import it to our TypeScript code like this:
import { type } from "ArkType"
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"
})
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)
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:
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)
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)
Union types can be defined as below:
type("'a'|'b'|'c'");
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)
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
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.*$/});
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'
The instanceof
operator is another very useful operator:
class Ark {}
const arkChecker = type(["instanceof", Ark])
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]
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])
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)
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 string
s, 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",
...})
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')
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" }
})
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 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.
Top comments (0)