ts-runtime-checks

A typescript transformer which automatically generates validation code from your types
GitHub
307
Created 3 years ago, last commit 11 days ago
2 contributors
313 commits
Stars added on GitHub, month by month
10
11
12
1
2
3
4
5
6
7
8
9
2023
2024
Stars added on GitHub, per day, on average
Yesterday
=
Last week
+0.1
/day
Last month
+0.4
/day
Last 12 months
+0.4
/day
Package on NPM
Monthly downloads on NPM
10
11
12
1
2
3
4
5
6
7
8
9
2023
2024
No dependencies
README

ts-runtime-checks

A typescript transformer which automatically generates validation code from your types. Think of it as a validation library like ajv and zod, except it completely relies on the typescript compiler, and generates vanilla javascript code on demand. This comes with a lot of advantages:

  • It's just types - no boilerplate or schemas needed.
  • Only validate where you see fit.
  • Code is generated during the transpilation phase, and can be easily optimized by V8.
  • Powerful - built on top of typescript's type system, which is turing-complete.

Here are some examples you can try out in the playground:

Asserting function parameters:

// Special `Assert` type get detected and generates validation code
function greet(name: Assert<string>, age: Assert<number>): string {
    return `Hello ${name}, you are ${age} years old!`;
}

// Transpiles to:
function greet(name, age) {
    if (typeof name !== "string") throw new Error("Expected name to be a string");
    if (typeof age !== "number") throw new Error("Expected age to be a number");
    return `Hello ${name}, you are ${age} years old!`;
}

Checking whether a value is of a certain type:

interface User {
    name: string;
    age: Min<13>;
}

const maybeUser = {name: "GoogleFeud", age: "123"};
// `is` function transpiles to the validation code
const isUser = is<User>(maybeUser);

// Transpiles to:
const isUser = typeof maybeUser === "object" && maybeUser !== null && typeof maybeUser.name === "string" && typeof maybeUser.age === "number" && maybeUser.age > 13;

Pattern Matching:

type WithValue = {value: string};
// `createMatch` function creates a pattern-matching function
const extractString = createMatch<string>([
    (value: string | number) => value.toString(),
    ({value}: WithValue) => value,
    () => {
        throw new Error("Could not extract string.");
    }
]);

//Transpiles to:
const extractString = value_1 => {
    if (typeof value_1 === "string") return value_1.toString();
    else if (typeof value_1 === "number") return value_1.toString();
    else if (typeof value_1 === "object" && value_1 !== null) {
        if (typeof value_1.value === "string") {
            let {value} = value_1;
            return value;
        }
    }
    throw new Error("Could not extract string.");
};

Usage

npm i --save-dev ts-runtime-checks
Usage with ts-patch
npm i --save-dev ts-patch

and add the ts-runtime-checks transformer to your tsconfig.json:

"compilerOptions": {
//... other options
"plugins": [
        { "transform": "ts-runtime-checks" }
    ]
}

Afterwards you can either use the tspc CLI command to transpile your typescript code.

Usage with ts-loader
const TsRuntimeChecks = require("ts-runtime-checks").default;

options: {
    getCustomTransformers: program => {
        before: [TsRuntimeChecks(program)];
    };
}
Usage with ts-node

To use transformers with ts-node, you'll have to change the compiler in the tsconfig.json:

npm i --save-dev ts-patch
"ts-node": {
    "compiler": "ts-patch"
  },
  "compilerOptions": {
    "plugins": [
        { "transform": "ts-runtime-checks" }
    ]
  }

ts-runtime-checks in depth

Markers

Markers are typescript type aliases which are detected by the transformer. These types don't represent actual values, but they tell the transformer what code to generate. Think of them as functions!

By far the most important marker is Assert<T>, which tells the transpiler to validate the type T. There are also utility markers which can be used inside an Assert marker to customize the validation in some way or to add extra checks. Here's the list of all utility markers:

  • Check<Condition, Error, Id, Value> - Checks if Condition is true for the value.
  • NoCheck<Type>- Doesn't generate checks for the provided type.
  • ExactProps<Obj, removeExtra, useDeleteOperator> - Makes sure the value doesn't have any excessive properties.
  • Expr<string> - Turns the string into an expression. Can be used in markers which require a javascript value.
  • Infer<Type> / Resolve<Type> - Creating validation for type parameters.

The library also exports a set of built-in Check type aliases, which can be used on existing types to add extra checks:

  • Min<Size> / Max<Size> - Check if a number is within bounds.
  • Int / Float - Limit a number to integer / floating point.
  • Matches<Regex> - Check if the value matches a pattern.
  • MaxLen<Size> / MinLen<Size> / Length<Size> - Used with anything that has a length property to check if it's within bounds.
  • Eq - Compares the value with the provided expression.
  • Not - Negates a Check.

Assert<Type, Action>

The Assert marker asserts that a value is of the provided type by adding validation code that gets executed during runtime. If the value doesn't match the type, the code will either return a value or throw an error, depending on what Action is:

  • Type literals (123, "hello", undefined, true, false) - The literal will be returned.
  • Expr<Type> - The expression will be returned.
  • ErrorMsg<rawErrors> - The error message will be returned.
  • ThrowError<ErrorType, rawErrors> - An error of type ErrorType will be thrown.

If rawErrors is true, instead of an error string, the transformer will pass / return an object like this:

{
    // The value of the item that caused it
    value: any;
    // The name of the value
    valueName: string;
    // Information about the expected type
    expectedType: TypeData;
}

By default, ThrowError<Error> is passed to Assert.

function onMessage(msg: Assert<string>, content: Assert<string, false>, timestamp: Assert<number, ThrowError<RangeError, true>>) {
    // ...
}

function onMessage(msg, content, timestamp) {
    if (typeof msg !== "string") throw new Error("Expected msg to be a string");
    if (typeof content !== "string") return false;
    if (typeof timestamp !== "number") throw new RangeError({value: timestamp, valueName: "timestamp", expectedType: {kind: 0}});
}

Check<Condition, Error, ID, Value>

Allows you to create custom conditions by providing a string containing javascript code, or a reference to a function.

  • You can use the $self variable to get the value that's currently being validated.
  • You can use the $parent function to get the parent object of the value. You can pass a number to get nested parents.

Error is a custom error string message that will get displayed if the check fails.

type StartsWith<T extends string> = Check<`$self.startsWith("${T}")`, `to start with "${T}"`, "startsWith", T>;

function test(a: Assert<string & StartsWith<"a">>) {
    return true;
}

// Transpiles to:
function test(a) {
    if (typeof a !== "string" || !a.startsWith("a")) throw new Error('Expected a to be a string, to start with "a"');
    return true;
}

You can combine checks using the & (intersection) operator:

// MaxLen and MinLen are types included in the library
function test(a: Assert<string & StartsWith<"a"> & MaxLen<36> & MinLen<3>>) {
    return true;
}

// Transpiles to:
function test(a) {
    if (typeof a !== "string" || !a.startsWith("a") || a.length > 36 || a.length < 3)
        throw new Error('Expected a to be a string, to start with "a", to have a length less than 36, to have a length greater than 3');
    return true;
}

The ID and Value type parameters get used when you want to receive a raw error. You don't need to use them if you don't make use of raw errors. They get passed to the expectedType object, where ID is the key and Value is the value:

function test(a: Assert<string & StartsWith<"a">, ThrowError<Error, true>>) {
    return 1;
}

// Transpiles to:
function test(a) {
    if (typeof a !== "string" || !a.startsWith(a)) throw new Error({value: a, valueName: "a", expectedType: {kind: 1, startsWith: "a"}});
    return 1;
}

NoCheck<Type>

Skips validating the value.

interface UserRequest {
    name: string;
    id: string;
    child: NoCheck<UserRequest>;
}

function test(req: Assert<UserRequest>) {
    // Your code...
}

// Transpiles to:
function test(req) {
    if (typeof req !== "object" || req === null) throw new Error("Expected req to be an object");
    if (typeof req.name !== "string") throw new Error("Expected req.name to be a string");
    if (typeof req.id !== "string") throw new Error("Expected req.id to be a string");
}

ExactProps<Type, removeExtra, useDeleteOperator>

Checks if an object has any "excessive" properties (properties which are not on the type but they are on the object).

If removeExtra is true, then instead of an error getting thrown, any excessive properties will be deleted in place from the object.

If useDeleteOperator is true, then the delete operator will be used to delete the property, otherwise the property will get set to undefined.

function test(req: unknown) {
    return req as Assert<ExactProps<{a: string; b: number; c: [string, number]}>>;
}

// Transpiles to:

function test(req) {
    if (typeof req !== "object" || req === null) throw new Error("Expected req to be an object");
    if (typeof req.a !== "string") throw new Error("Expected req.a to be a string");
    if (typeof req.b !== "number") throw new Error("Expected req.b to be a number");
    if (!Array.isArray(req.c)) throw new Error("Expected req.c to be an array");
    if (typeof req.c[0] !== "string") throw new Error("Expected req.c[0] to be a string");
    if (typeof req.c[1] !== "number") throw new Error("Expected req.c[1] to be a number");
    for (let p_1 in req) {
        if (p_1 !== "a" && p_1 !== "b" && p_1 !== "c") throw new Error("Property req." + p_1 + " is excessive");
    }
    return req;
}

Infer<Type>

You can use this utility type on type parameters - the transformer is going to go through all call locations of the function the type parameter belongs to, figure out the actual type used, create a union of all the possible types and validate it inside the function body.

export function test<T>(body: Assert<Infer<T>>) {
    return true;
}

// in fileA.ts
test(123);

// in FileB.ts
test([1, 2, 3]);

// Transpiles to:
function test(body) {
    if (typeof body !== "number")
        if (!Array.isArray(body)) throw new Error("Expected body to be one of number, number[]");
        else {
            for (let i_1 = 0; i_1 < len_1; i_1++) {
                if (typeof body[i_1] !== "number") throw new Error("Expected body[" + i_1 + "] to be a number");
            }
        }
    return true;
}

Resolve<Type>

Pass a type parameter to Resolve<Type> to move the validation logic to the call site, where the type parameter is resolved to an actual type.

Currently, this marker has some limitations:

  • Can only be used in Assert markers (so you can't use it in check or is)
  • Can only be used in parameter declarations (so no as assertions)
  • The parameter name has to be an identifier (no deconstructions)
  • Cannot be used on rest parameters
function validateBody<T>(data: Assert<{body: Resolve<T>}>) {
    return data.body;
}

const validatedBody = validateBody<{
    name: string;
    other: boolean;
}>({body: JSON.parse(process.argv[2])});

// Transpiles to:
function validateBody(data) {
    return data.body;
}
const receivedBody = JSON.parse(process.argv[2]);
const validatedBody = (() => {
    const data = {body: receivedBody};
    if (typeof data.body !== "object" && data.body !== null) throw new Error("Expected data.body to be an object");
    if (typeof data.body.name !== "string") throw new Error("Expected data.body.name to be a string");
    if (typeof data.body.other !== "boolean") throw new Error("Expected data.body.other to be a boolean");
    return validateBody(data);
})();

Transformations

You can also describe transformations in your types using the Transform marker. It accepts a reference to a function, a string containing javascript code, or a combination of both:

const timestampToDate = (ts: number) => new Date(ts);
const incrementAge = (age: number) => age + 1;

type User = {
    username: string;
    createdAt: Transform<typeof timestampToDate>;
    age: Transform<["+$self", typeof incrementAge], string>;
};

It's recommended to use function references because all of the types will be inferred for you. In the example above, we're able to tell typescript that createdAt is of type number before it gets transformed to Date. However, in age, we have to specify the initial type (string) because the first transformation is a code string.

Once you have a type you can transform, you can use the transform utility function to actually perform the transformation:

const myUser: User = {
    username: "GoogleFeud",
    createdAt: 1716657364400,
    age: "123"
}

console.log(transform<User>(myUser))

// Transpiles to:

let result_1;
result_1 = {};
result_1.createdAt = timestampToDate(myUser.createdAt);
result_1.age = incrementAge(+myUser.age);
result_1.username = myUser.username;
console.log(result_1);

The second type parameter of the transform function is an Action, and if it's provided, the type will be validated before being transformed. Check out the Assert section for all possible actions.

You can also perform conditional transformations via unions:

interface ConditionalTransform {
    // "age" is either a number or a string
    age: number | Transform<typeof stringToNum>,
    // "id" is either a string or a number that must be larger than 3.
    id: Transform<typeof stringToNum> | Min<3> & Transform<"$self + 1">
}

transform<ConditionalTransform, ThrowError>({ age: "3", id: 12 })

// Transpiles to:
let result_1;
result_1 = {};
if (typeof value_1.id === "string") {
    result_1.id = stringToNum(value_1.id);
} else if (typeof value_1.id === "number" && value_1.id >= 3) {
    result_1.id = value_1.id + 1;
} else
    throw new Error("Expected value.id to be one of string | number, to be greater than 3");
if (typeof value_1.age === "string") {
    result_1.age = stringToNum(value_1.age);
} else if (typeof value_1.age === "number") {
    result_1.age = value_1.age;
} else
    throw new Error("Expected value.age to be one of string | number");

You can also use the PostCheck type to perform checks after the value has been transformed! Check out the PostCheck examples and other pretty crazy conditional transformations in this unit test

as assertions

You can use as type assertions to validate values in expressions. The transformer remembers what's safe to use, so you can't generate the same validation code twice.

interface Args {
    name: string;
    path: string;
    output: string;
    clusters?: number;
}

const args = JSON.parse(process.argv[2] as Assert<string>) as Assert<Args>;

// Transpiles to:
if (typeof process.argv[2] !== "string") throw new Error("Expected process.argv[2] to be a string");
const value_1 = JSON.parse(process.argv[2]);
if (typeof value_1 !== "object" || value_1 === null) throw new Error("Expected value to be an object");
if (typeof value_1.name !== "string") throw new Error("Expected value.name to be a string");
if (typeof value_1.path !== "string") throw new Error("Expected value.path to be a string");
if (typeof value_1.output !== "string") throw new Error("Expected value.output to be a string");
if (value_1.clusters !== undefined && typeof value_1.clusters !== "number") throw new Error("Expected value.clusters to be a number");
const args = value_1;

is<Type>(value)

Every call to this function gets replaced with an immediately-invoked arrow function, which returns true if the value matches the type, false otherwise.

const val = JSON.parse('["Hello", "World"]');
if (is<[string, number]>(val)) {
    // val is guaranteed to be [string, number]
}

// Transpiles to:

const val = JSON.parse('["Hello", "World"]');
if (Array.isArray(val) && typeof val[0] === "string" && typeof val[1] === "number") {
    // Your code
}

check<Type, rawErrors>(value)

Every call to this function gets replaced with an immediately-invoked arrow function, which returns the provided value, along with an array of errors.

If rawErrors is true, the raw error data will be pushed to the array instead of error strings.

const [value, errors] = check<[string, number]>(JSON.parse('["Hello", "World"]'));
if (errors.length) console.log(errors);

// Transpiles to:

const value = JSON.parse('["Hello", "World"]');
const errors = [];
if (!Array.isArray(value)) errors.push("Expected value to be an array");
else {
    if (typeof value[0] !== "string") errors.push("Expected value[0] to be a string");
    if (typeof value[1] !== "number") errors.push("Expected value[1] to be a number");
}
if (errors.length) console.log(errors);

createMatch<ReturnType, InputType>(function[], noDiscriminatedObjAssert)

Creates a match function which performs pattern-matching on the input type, based on the provided functions. Each function in the array is a match arm, where the type of the first parameter is the type the arm is matching against:

// We want the match function to return a string and to accept a number
const resolver = createMatch<string, number>([
    // Match arm which catches the values 0 or 1
    (_: 0 | 1) => "not many",
    // Match arm which catches any number less than 9
    (_: Max<9>) => "a few",
    // Match arm which catches any number that hasn't already been caught
    (_: number) => "lots"
]);

// Transpiles to:
const resolver = value_1 => {
    if (typeof value_1 === "number") {
        if (value_1 === 1 || value_1 === 0) return "not many";
        else if (value_1 < 9) return "a few";
        else return "lots";
    }
};

You could also have a default match arm by omitting the parameter, or giving it the unknown or any type:

const toNumber: (value: unknown) => number = createMatch<number>([
    (value: string | boolean) => +value,
    (value: number) => value,
    (value: Array<string> | Array<number> | Array<boolean>) => value.map(v => toNumber(v)).reduce((val, acc) => val + acc, 0),
    (value: unknown) => {
        throw new Error("Unexpected value: " + value);
    }
]);

// Transpiles to:
const toNumber = value_1 => {
    if (typeof value_1 === "boolean") return +value_1;
    else if (typeof value_1 === "string") return +value_1;
    else if (typeof value_1 === "number") return value_1;
    else if (Array.isArray(value_1)) {
        if (value_1.every(value_2 => typeof value_2 === "string") || value_1.every(value_3 => typeof value_3 === "number") || value_1.every(value_4 => typeof value_4 === "boolean"))
            return value_1.map(v => toNumber(v)).reduce((val, acc) => val + acc, 0);
    }
    throw new Error("Unexpected value: " + value_1);
};

If the discriminatedObjAssert parameter is set to true, then if you have a discriminated object (object which has a literal property), only the literal property will be validated. Use this if you have already validated the source or if you know that it's correct.

Destructuring

If a value is a destructured object / array, then only the deconstructed properties / elements will get validated.

function test({
    user: {
        skills: [skill1, skill2, skill3]
    }
}: Assert<
    {
        user: {
            username: string;
            password: string;
            skills: [string, string?, string?];
        };
    },
    undefined
>) {
    // Your code
}

// Transpiles to:
function test({
    user: {
        skills: [skill1, skill2, skill3]
    }
}) {
    if (typeof skill1 !== "string") return undefined;
    if (skill2 !== undefined && typeof skill2 !== "string") return undefined;
    if (skill3 !== undefined && typeof skill3 !== "string") return undefined;
}

Supported types and code generation

  • strings and string literals
    • typeof value === "string" or value === "literal"
  • numbers and number literals
    • typeof value === "number" or value === 420
  • boolean
    • value === true || value === false
  • symbol
    • typeof value === "symbol"
  • bigint
    • typeof value === "bigint"
  • null
    • value === null
  • undefined
    • value === undefined
  • Tuples ([a, b, c])
    • Array.isArray(value)
    • Each type in the tuple gets checked individually.
  • Arrays (Array<a>, a[])
    • Array.isArray(value)
    • Each value in the array gets checked via a for loop.
  • Interfaces and object literals ({a: b, c: d})
    • typeof value === "object"
    • value !== null
    • Each property in the object gets checked individually.
  • Classes
    • value instanceof Class
  • Enums
  • Unions (a | b | c)
    • Discriminated unions - Each type in the union must have a value that's either a string or a number literal.
  • Function type parameters
    • Inside the function as one big union with the Infer utility type.
    • At the call site of the function with the Resolve utility type.
  • Recursive types
    • A function gets generated for recursive types, with the validation code inside.
    • Note: Currently, because of limitations, errors in recursive types are a lot more limited.

Complex types

Markers can be used in type aliases, so you can easily create shortcuts to common patterns:

Combining checks:

// Combining all number related checks into one type
type Num<min extends number | undefined = undefined, max extends number | undefined = undefined, typ extends Int | Float | undefined = undefined> = number &
    (min extends number ? Min<min> : number) &
    (max extends number ? Max<max> : number) &
    (typ extends undefined ? number : typ);

function verify(n: Assert<Num<2, 10, Int>>) {
    // ...
}

// Transpiles to:
function verify(n) {
    if (typeof n !== "number" || n < 2 || n > 10 || n % 1 !== 0) throw new Error("Expected n to be a number, to be greater than 2, to be less than 10, to be an int");
}

Transformer options

JSON Schema

The transformer allows you to turn any of the types you use in your project into JSON Schemas with the jsonSchema configuration option:

"compilerOptions": {
//... other options
"plugins": [
        {
            "transform": "ts-runtime-checks",
            "jsonSchema": {
                "dist": "./schemas"
            }
        }
    ]
}

Using the configuration above, all types in your project will be turned into JSON Schemas and be saved in the ./schemas directory, each one in different file. You can also filter types by using either the types option or the typePrefix option:

"jsonSchema": {
    "dist": "./schemas",
    // Only specific types will be turned to schemas
    "types": ["User", "Member", "Guild"],
    // Only types with names that start with a specific prefix will be turned to schemas
    "typePrefix": "$"
}

assertAll

Setting this option to true will add assertion code to ALL function parameters and as assertions. The assertion code will throw an error. You can use the NoCheck marker to override this behaviour.

Contributing

ts-runtime-checks is being maintained by a single person. Contributions are welcome and appreciated. Feel free to open an issue or create a pull request at https://github.com/GoogleFeud/ts-runtime-checks