ts-runtime-checks

A typescript transformer which automatically generates validation code from...

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 itcompletely 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:
  1. ```ts
  2. function greet(name: Assert<string>, age: Assert<number>) : string {
  3.     return `Hello ${name}, you are ${age} years old!`
  4. }

  5. // Transpiles to:
  6. function greet(name, age) {
  7.     if (typeof name !== "string") throw new Error("Expected name to be a string");
  8.     if (typeof age !== "number") throw new Error("Expected age to be a number");
  9.     return `Hello ${name}, you are ${age} years old!`;
  10. }
  11. ```
Checking whether a value is of a certain type:
  1. ```ts
  2. interface User {
  3.     name: string,
  4.     age: number & Min<13>
  5. }

  6. const maybeUser = { name: "GoogleFeud", age: "123" }
  7. const isUser = is<User>(maybeUser);

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

Usage


  1. ```
  2. npm i --save-dev ts-runtime-checks
  3. ```

Usage with ts-patch

  1. ```
  2. npm i --save-dev ts-patch
  3. ```

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

  1. ```json
  2. "compilerOptions": {
  3. //... other options
  4. "plugins": [
  5.         { "transform": "ts-runtime-checks" }
  6.     ]
  7. }
  8. ```

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

Usage with ts-loader

  1. ```js
  2. const TsRuntimeChecks = require("ts-runtime-checks").default;

  3. options: {
  4.       getCustomTransformers: program => {
  5.         before: [TsRuntimeChecks(program)]
  6.       }
  7. }
  8. ```

Usage with ts-node

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

  1. ```
  2. npm i --save-dev ts-patch
  3. ```

  1. ```json
  2. "ts-node": {
  3.     "compiler": "ts-patch"
  4.   },
  5.   "compilerOptions": {
  6.     "plugins": [
  7.         { "transform": "ts-runtime-checks" }
  8.     ]
  9.   }
  10. ```

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` - Used with the `number` type to check if a number is within bounds.
- Integer / Float - Used with the number type to limit the value to integers / floating points.
- `MaxLen` / `MinLen` / `Length` - Used with anything that has a `length` property to check if it's within bounds.- `Matches` - Used with the `string` type to check if the value matches a pattern.
- Not - Negates a Check.
- Or - Logical OR operator for 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:

  1. ```js
  2. {
  3.     // The value of the item that caused it
  4.     value: any
  5.     // The name of the value
  6.     valueName: string
  7.     // Information about the expected type
  8.     expectedType: TypeData
  9. }
  10. ```

By default, `ThrowError` is passed to `Assert`.

  1. ```ts
  2. function onMessage(
  3.     msg: Assert<string>,
  4.     content: Assert<string, false>,
  5.     timestamp: Assert<number, ThrowError<RangeError, true>>
  6.     ) {
  7.         // ...
  8. }

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

#### `Check`

Allows you to create custom conditions by providing a string containing javascript code.

- 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. ID and Value are parameters that the transformer uses internally, so you don't need to pass anything to them.

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

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

  6. // Transpiles to:
  7. function test(a) {
  8.     if (typeof a !== "string" || !a.startsWith("a")) throw new Error("Expected a to be a string, to start with \"a\"");
  9.     return true;
  10. }
  11. ```

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

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

  6. // Transpiles to:
  7. function test(a) {
  8.     if (typeof a !== "string" || !a.startsWith("a") || a.length > 36 || a.length < 3)
  9.         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");
  10.     return true;
  11. }
  12. ```

You can also use Check types on their own, you don't need to combine them with a normal type like string or number.

#### `NoCheck`

Skips validating the value.

  1. ```ts
  2. interface UserRequest {
  3.     name: string,
  4.     id: string,
  5.     child: NoCheck<UserRequest>
  6. }

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

  10. // Transpiles to:
  11. function test(req) {
  12.     if (typeof req !== "object" || req === null) throw new Error("Expected req to be an object");
  13.     if (typeof req.name !== "string") throw new Error("Expected req.name to be a string");
  14.     if (typeof req.id !== "string") throw new Error("Expected req.id to be a string");
  15. }
  16. ```

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

  1. ```ts
  2. function test(req: unknown) {
  3.     return req as Assert<ExactProps<{a: string, b: number, c: [string, number]}>>;
  4. }

  5. // Transpiles to:

  6. function test(req) {
  7.     if (typeof req !== "object" || req === null) throw new Error("Expected req to be an object");
  8.     if (typeof req.a !== "string") throw new Error("Expected req.a to be a string");
  9.     if (typeof req.b !== "number") throw new Error("Expected req.b to be a number");
  10.     if (!Array.isArray(req.c)) throw new Error("Expected req.c to be an array");
  11.     if (typeof req.c[0] !== "string") throw new Error("Expected req.c[0] to be a string");
  12.     if (typeof req.c[1] !== "number") throw new Error("Expected req.c[1] to be a number");
  13.     for (let p_1 in req) {
  14.         if (p_1 !== "a" && p_1 !== "b" && p_1 !== "c") throw new Error("Property req." + p_1 + " is excessive");
  15.     }
  16.     return req;
  17. }
  18. ```

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

  1. ```ts
  2. export function test<T>(body: Assert<Infer<T>>) {
  3.     return true;
  4. }

  5. // in fileA.ts
  6. test(123);

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

  9. // Transpiles to:
  10. function test(body) {
  11.     if (typeof body !== "number")
  12.         if (!Array.isArray(body))
  13.             throw new Error("Expected body to be one of number, number[]");
  14.         else {
  15.             for (let i_1 = 0; i_1 < len_1; i_1++) {
  16.                 if (typeof body[i_1] !== "number")
  17.                     throw new Error("Expected body[" + i_1 + "] to be a number");
  18.             }
  19.         }
  20.     return true;
  21. }
  22. ```

#### `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).
- If used in a parameter declaration, the parameter name has to be an identifier (no deconstructions).
- Cannot be used on rest parameters.

  1. ```ts
  2. function validateBody<T>(data: Assert<{ body: Resolve<T> }>) {
  3.     return data.body;
  4. }

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

  9. // Transpiles to:
  10. function validateBody(data) {
  11.     return data.body;
  12. }
  13. const receivedBody = JSON.parse(process.argv[2]);
  14. const validatedBody = (() => {
  15.     const data = { body: receivedBody };
  16.     if (typeof data.body !== "object" && data.body !== null)
  17.         throw new Error("Expected data.body to be an object");
  18.     if (typeof data.body.name !== "string")
  19.         throw new Error("Expected data.body.name to be a string");
  20.     if (typeof data.body.other !== "boolean")
  21.         throw new Error("Expected data.body.other to be a boolean");
  22.     return validateBody(data);
  23. })();
  24. ```

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
    - typeof value === "boolean"
- 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)
    - Object unions - If you want to have a union of multiple possible objects, each object must have at least one 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.

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.

  1. ```ts
  2. interface Args {
  3.     name: string,
  4.     path: string,
  5.     output: string,
  6.     clusters?: number
  7. }

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

  9. // Transpiles to:
  10. if (typeof process.argv[2] !== "string")
  11.     throw new Error("Expected process.argv[2] to be a string");
  12. const value_1 = JSON.parse(process.argv[2]);
  13. if (typeof value_1 !== "object" || value_1 === null)
  14.     throw new Error("Expected value to be an object");
  15. if (typeof value_1.name !== "string")
  16.     throw new Error("Expected value.name to be a string");
  17. if (typeof value_1.path !== "string")
  18.     throw new Error("Expected value.path to be a string");
  19. if (typeof value_1.output !== "string")
  20.     throw new Error("Expected value.output to be a string");
  21. if (value_1.clusters !== undefined && typeof value_1.clusters !== "number")
  22.     throw new Error("Expected value.clusters to be a number");
  23. const args = value_1;
  24. ```

### `is(value)` utility function

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

  1. ```ts
  2. const val = JSON.parse("[\"Hello\", \"World\"]");;
  3. if (is<[string, number]>(val)) {
  4.     // val is guaranteed to be [string, number]
  5. }

  6. // Transpiles to:

  7. const val = JSON.parse("[\"Hello\", \"World\"]");
  8. if (Array.isArray(val) && typeof val[0] === "string" && typeof val[1] === "number") {
  9.     // Your code
  10. }
  11. ```

### `check(value)` utility function

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

  1. ```ts
  2. const [value, errors] = check<[string, number]>(JSON.parse("[\"Hello\", \"World\"]"));
  3. if (errors.length) console.log(errors);

  4. // Transpiles to:

  5. const value = JSON.parse("[\"Hello\", \"World\"]");
  6. const errors = [];
  7. if (!Array.isArray(value)) errors.push("Expected value to be an array");
  8. else {
  9.     if (typeof value[0] !== "string") errors.push("Expected value[0] to be a string");
  10.     if (typeof value[1] !== "number") errors.push("Expected value[1] to be a number");
  11. }
  12. if (errors.length) console.log(errors);
  13. ```

Destructuring


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

  1. ```ts
  2. function test({user: { skills: [skill1, skill2, skill3] }}: Assert<{
  3.     user: {
  4.         username: string,
  5.         password: string,
  6.         skills: [string, string?, string?]
  7.     }
  8. }, undefined>) {
  9.     // Your code
  10. }

  11. // Transpiles to:
  12. function test({ user: { skills: [skill1, skill2, skill3] } }) {
  13.     if (typeof skill1 !== "string") return undefined;
  14.     if (skill2 !== undefined && typeof skill2 !== "string") return undefined;
  15.     if (skill3 !== undefined && typeof skill3 !== "string") return undefined;
  16. }
  17. ```

Complex types


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

Combining checks:
  1. ```ts
  2. // Combining all number related checks into one type
  3. type Num<
  4.     min extends number|undefined = undefined,
  5.     max extends number|undefined = undefined,
  6.     typ extends Int|Float|undefined = undefined>
  7.         = number & (min extends number ? Min<min> : number) & (max extends number ? Max<max> : number) & (typ extends undefined ? number : typ);

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

  11. // Transpiles to:
  12. function verify(n) {
  13.     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");
  14. }
  15. ```

Generating JSON Schemas from your types



  1. ```js
  2. "compilerOptions": {
  3. //... other options
  4. "plugins": [
  5.         {
  6.             "transform": "ts-runtime-checks",
  7.             "jsonSchema": {
  8.                 "dist": "./schemas"
  9.             }
  10.         }
  11.     ]
  12. }
  13. ```

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:

  1. ```js
  2. "jsonSchema": {
  3.     "dist": "./schemas",
  4.     // Only specific types will be turned to schemas
  5.     "types": ["User", "Member", "Guild"],
  6.     // Only types with names that start with a specific prefix will be turned to schemas
  7.     "typePrefix": "$"
  8. }
  9. ```


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