DEV Community

kimuson
kimuson

Posted on

Introduction to type-predicates-generator, which automatically generates type guards from TypeScript type definitions

I've created a tool to automatically generate user-defined type guards (type predicate) and assertion functions from TypeScript type definitions. By automatically generating user-defined type guards, you can protect the types of your applications safely and easily without writing wrong implementation as type predicate.

https://github.com/d-kimuson/type-predicates-generator

type predicate and problems

If you use type annotations or as <type> for typing a value that comes from an external source such as API or JSON parsing, you won't be able to notice when you get an unexpected value.

type Task = {
  id: number
  titile: string
  description: string
}

const task: Task = JSON.parse('...') // Write annotations for functions that return any type
task /* :task */ // value is typed as Task even if the type is not match to the Task type
Enter fullscreen mode Exit fullscreen mode

If the type is different from what it actually is, it is undesirable because it reduces the benefits of TypeScript, both in terms of application safety and development experience.

In such a case, you can use type predicate (v is <type>) to do type guarding using a user-defined function.

It allows you safe typing of untyped value.

// define type predicate
function isTask(value: unknown): value is Task {
  return (
    typeof value === 'object' &&
    value ! == null &&
    'id' in value &&
    typeof value.id === 'number' &&
    'title' in value &&
    typeof value.title === 'string' &&
    'description' in value &&
    typeof value.description === 'string'
  )
}

// using type predicate
if (isTask(task)) {
  // if isTask returns true, assume task is of type Task
  task /* :Task */ }
}
Enter fullscreen mode Exit fullscreen mode

This is safer than using as or type annotations, but TypeScript does not take care of the correct implementation of this type predicate function.

In the extreme case, a messed up implementation such as const isTask = (v: unknown) v is Task => true will narrow down the type to Task without getting angry.

I don't think you ever write such an implementation, of course, but

  • A case where the implementation was correct at the time of writing, but the Task type is changed and isTask becomes an inappropriate implementation (adding a property)
  • A simple implementation error (as you can see in isTask, it is quite complicated to write object property checks properly, and there are also shared types, array child element checks, etc., so mistakes can be made)

In some cases, incorrect runtime checking functions may be introduced due to them.

Also, since it is difficult to write type predicates for each type, psychologically speaking, it is easy to compromise with type annotations or as without writing the type predicate itself.

Introduction to type-predicates-generator

The type-predicates-generator solves these problems by automatically generating a type predicate function from a type definition.

It can be installed from npm.

https://www.npmjs.com/package/type-predicates-generator

$ yarn add -D type-predicates-generator # or npm
Enter fullscreen mode Exit fullscreen mode

The type predicate function will be generated automatically by passing the appropriate options to the type-predicates-generator command.

$ yarn run type-predicates-generator -f 'path/to/types/**/*.ts' -o '. /type-predicates.ts'
Enter fullscreen mode Exit fullscreen mode

Then, automatically generates predicates functions from exported type declarations (type alias and interface) in files matching path/to/types/**/*.ts, and writes them to type-predicates.ts.

If you run type-predicates-generator on the Task type used in the above example, it will automatically generate the following

// auto-generated predicate definitions
import type { Task } from "path/to/your-type-declare"

const isNumber = (value: unknown): value is number => typeof value === "number"
const isString = (value: unknown): value is string => typeof value === "string"
const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value ! == null && !Array.isArray(value)

export const isTask = (arg_0: unknown): arg_0 is Task =>
  isObject(arg_0) &&
  "id" in arg_0 &&
  isNumber(arg_0["id"]) &&
  "title" in arg_0 &&
  isString(arg_0["title"]) &&
  "description" in arg_0 &&
  isString(arg_0["description"])
Enter fullscreen mode Exit fullscreen mode

Now we can use the auto-generated function to protect our application by using it when values come in from the outside!

 import { isTask } from 'path/to/type-predicates'.

 fetch("path/to/api").then(async (data) => {
- const json: Task = await data.json(); // dangerous typing that doesn't check if it's really of type Task.
+ const json /* :any */ = await data.json();
+ if (!isTask(json)) throw new Error('Oops'); // throw an exception if the check fails

   json /* :Task */ }
 })
Enter fullscreen mode Exit fullscreen mode

Now you can safely and easily type values that come from the outside.

If you specify the -a option, assertion function can also be generated automatically. The assertion function is more appropriate for cases like the one above.

// which is additionally auto-generated by `-a` option
function assertIsTask(value: unkonwn): asserts value is Task {
  if (isTask(task)) throw new TypeError(`value must be Task but received ${value}`)
}

// Use
const json /* :any */ = await data.json();
assertIsTask(json) // If it fails, an exception is raised
json /* :Task */
Enter fullscreen mode Exit fullscreen mode

Since it is auto-generated, there is no need to write a predicate, and the psychological hurdle is low.

It also supports watch mode (-w), so you can run watch during development and reflect changes as they happen.

Image description

See the repository for cli other options

https://github.com/d-kimuson/type-predicates-generator#cli-options

What can be checked and what can't

Basically, I've tried to support all data structures that can be received as JSON (so if you find any missing, please raise an issue).

https://github.com/d-kimuson/type-predicates-generator/issues

On the other hand

  • Functions/Methods
  • Promise
  • Data structures with circular references (such as obj1.recursive = obj2, obj2.recursive = obj1)
    • When checking properties, they go to each other to check each other's properties, resulting in an infinite loop.

It is not possible to perform checks in them.

On the other hand, all type operations are supported.

Complex types such as Mapped Types, Conditional Types, etc. can be generated without any problems, as long as they eventually resolve to JSON serializable types or their intersection or union types

// From: Partial using Mapped Types
type PartialTask = Partial<Task>;

// Generated: generated from the last resolved type
export const isPartialTask = (arg_0: unknown): arg_0 is PartialTask =>
  isObject(arg_0) &&
  ((arg_1: unknown): boolean => isUnion([isUndefined, isNumber])(arg_1))(
    arg_0["id"]
  ) &&
  ((arg_1: unknown): boolean => isUnion([isUndefined, isString])(arg_1))(
    arg_0["title"]
  ) &&
  ((arg_1: unknown): boolean => isUnion([isUndefined, isString])(arg_1))(
    arg_0["description"]
  )
Enter fullscreen mode Exit fullscreen mode

Use with openapi-generator

In the front-end, some tools such as openapi-generator, aspida, etc. are used to automatically generate response types.

It is also possible to include them directly in the type-predicates-generator generation, but since the type definitions tend to be huge and it takes a long time to generate them, you can re-export only the ones you use.

(However, it took more than 5 seconds even when I tried it with a 4000-line type definition generated by GraphQL Code Generator, which I use in my personal blog, so you may not have to worry about time to generate too much.)

For this use case, I include not only the type declarations but also the re-exported type definitions in the generation

export { Category } from ". /typescript-axios/api".
Enter fullscreen mode Exit fullscreen mode

There is a concrete example in the repository example. Types re-exported by re-export.ts You can see that the function is generated in type-predicate.ts from the type definition re-exported by re-export.ts. From the type definition re-exported by type-predicate.ts, we can see that the function has been generated

How is it implemented?

The Compiler API extracts the type information from the Glob-specified file and generates it automatically.

I'll leave the details of the Compiler API to another article.

  1. search Node for the matched file and pick up the declaration node of type alias and interface. 2.
  2. recursively pick up the types from the declaration nodes using TypeChecker, and 3. writes the exported type information as Exportable Type Information, which is defined independently.
  3. generate code based on the exported type information

This is how it works

https://github.com/d-kimuson/type-predicates-generator/blob/main/src/compiler-api/compiler-api-handler.ts

https://github.com/d-kimuson/type-predicates-generator/blob/main/src/generate/generate-type-predicates.ts

Other solutions

There is a major library io-ts that solves the problem that type predicate implementation may deviate from the type definition.

// Usage Example
import { isRight } from "fp-ts/lib/Either";
import * as t from 'io-ts'.

const TaskIO = t.type({
  id: t.number,
  title: t.string,
  description: t.string,
})

export type Task = t.TypeOf<typeof TaskIO>

// Example usage
const data /* :any */ = JSON.parse('...')
const result = TaskIO.decode(data)
if (isRight(result)) {
  const task /* :Task */ = result.right
}
Enter fullscreen mode Exit fullscreen mode

io-ts uses its own notation to define the types to be checked at runtime, and accepts TypeScript types from there (t.TypeOf(typeof Task)).

It's a very nice library, but the type definitions can't be written in TypeScript types.

  • Define an API type from an existing TS type through type operations.
  • Define API types from existing TS types through type operations.

In such a case, type-predicates-generator is a good choice.

Conclusion

So, that's my introduction to type-predicates-generator!
It's still a work in progress, but it's an easy tool for improving type safety, so I hope you'll give it a try!
PR and issues are welcome!

Top comments (0)