Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/GoogleFeud/ts-runtime-checks

A typescript transformer that automatically generates validation code from your types.
https://github.com/GoogleFeud/ts-runtime-checks

runtime transformer typescript validation

Last synced: 3 months ago
JSON representation

A typescript transformer that automatically generates validation code from your types.

Awesome Lists containing this project

README

        

# ts-runtime-checks

A typescript transformer which automatically generates validation code from your types. Think of it as a validation library like [ajv](https://ajv.js.org/guide/typescript.html) and [zod](https://zod.dev/), 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](https://googlefeud.github.io/ts-runtime-checks/):

**Asserting function parameters:**

```ts
// Special `Assert` type get detected and generates validation code
function greet(name: Assert, age: Assert): 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:**

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

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

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

**Pattern Matching:**

```ts
type WithValue = {value: string};
// `createMatch` function creates a pattern-matching function
const extractString = createMatch([
(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:

```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

```js
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
```

```json
"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`, 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` - Checks if `Condition` is true for the value.
- `NoCheck`- Doesn't generate checks for the provided type.
- `ExactProps` - Makes sure the value doesn't have any excessive properties.
- `Expr` - Turns the string into an expression. Can be used in markers which require a javascript value.
- `Infer` / `Resolve` - 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` / `Max` - Check if a number is within bounds.
- `Int` / `Float` - Limit a number to integer / floating point.
- `Matches` - Check if the value matches a pattern.
- `MaxLen` / `MinLen` / `Length` - 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`

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` - The expression will be returned.
- `ErrorMsg` - The error message will be returned.
- `ThrowError` - 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:

```js
{
// 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` is passed to `Assert`.

```ts
function onMessage(msg: Assert, content: Assert, timestamp: Assert>) {
// ...
}

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`

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.

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

function test(a: Assert>) {
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:

```ts
// MaxLen and MinLen are types included in the library
function test(a: Assert & 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:

```ts
function test(a: Assert, ThrowError>) {
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`

Skips validating the value.

```ts
interface UserRequest {
name: string;
id: string;
child: NoCheck;
}

function test(req: Assert) {
// 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`

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.

```ts
function test(req: unknown) {
return req as Assert>;
}

// 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`

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.

```ts
export function test(body: Assert>) {
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`

Pass a type parameter to `Resolve` 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

```ts
function validateBody(data: Assert<{body: Resolve}>) {
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:

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

type User = {
username: string;
createdAt: Transform;
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:

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

console.log(transform(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:

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

transform({ 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](https://github.com/GoogleFeud/ts-runtime-checks/blob/main/tests/integrated/transforms.test.ts)

### `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.

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

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

// 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(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.

```ts
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(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.

```ts
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(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:

```ts
// We want the match function to return a string and to accept a number
const resolver = createMatch([
// 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:

```ts
const toNumber: (value: unknown) => number = createMatch([
(value: string | boolean) => +value,
(value: number) => value,
(value: Array | Array | Array) => 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.

```ts
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

- `string`s and string literals
- `typeof value === "string"` or `value === "literal"`
- `number`s 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[]`)
- `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:**

```ts
// Combining all number related checks into one type
type Num = number &
(min extends number ? Min : number) &
(max extends number ? Max : number) &
(typ extends undefined ? number : typ);

function verify(n: Assert>) {
// ...
}

// 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");
}
```

### Generating JSON Schemas from your types

The transformer allows you to turn any of the types you use in your project into [JSON Schemas](https://json-schema.org/) with the `jsonSchema` configuration option:

```js
"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:

```js
"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": "$"
}
```

## 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