Skip to content

Compose validation parsers with static type inference, makes it easy to handle data from RESTful API

License

Notifications You must be signed in to change notification settings

beenotung/cast.ts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cat.ts logo

cast.ts

Compose validation parsers with static type inference; or
Auto-infer parsers from sample values
cast.ts makes it easy to handle data from RESTful API

npm Package Version Minified Package Size Minified and Gzipped Package Size

Inspired by Zod and tRPC with automatic type conversion, type reflection, sample values and auto-infer from sample value.

Feature Highlights

  • Explicit type conversion
  • Right-to-the-point error message
  • Static type inference
  • Composable: builder functions (i.e. optional()) return new parser instance
  • Convenience: support auto-infer parser from from sample value
  • Safe: Parse, don't type-check
  • Tiny: below 2kB minizipped
  • Zero dependencies
  • Isomorphic Package: works in Node.js and browsers
  • Works with plain Javascript, Typescript is not mandatory
  • Extensible: support meta-programming with type reflection and sample values

Introduction

cast.ts is an isomorphic package, it runs in both node.js and browsers.

You can use cast.ts to check against request data on the server, and also response data on the client. This double-checking approach add a safe layer between the interface (API) of separately implemented frontend and backend for easier debugging with specific error message and better security and maintainability.

Bonus: cast.ts also supports static type inference in Typescript project.

Installation

npm install cast.ts

You can also install cast.ts with pnpm, yarn, or slnpm

Usage Example

You can compose a parser by composing a wide range of parser builders, or auto infer a parser from sample value.

Composing Parsers

import { optional, object, int, array, id, string } from 'cast.ts'

let searchQuery = object({
  page: optional(int({ min: 1 })),
  count: optional(int({ max: 25 })),
  cat: optional(array(id(), { maybeSingle: true })),
  keyword: string({ minLength: 3 }),
})

type SearchQuery = ParseResult<typeof searchQuery>
// the inferred type of parse result will be like below
type SearchQuery = {
  page?: number
  count?: number
  cat: number[]
  keyword: string
}

// Example: https://localhost:8100/product/search?page=2&count=20&keyword=food&cat=12&cat=18
app.get('/product/search', async (req, res) => {
  // query is validated with inferred type
  let query = searchQuery.parse(req.query)
  console.log(query)
  // { page: 2, count: 20, cat: [ 12, 18 ], keyword: 'food' }
})

Noted that the parsed page, count are numbers, and the cat is array of numbers, instead of being string and array of strings in the original req.query from express router.

If the validation is not successful, the parser will throw an InvalidInputError. You can surround the call with try-catch to response specific error message to the client.

A full list of built-in parsers are documented in the Supported Parsers section below.

For more complete example, see examples/server.ts

Infer from Sample Value

You can use inferFromSampleValue() to auto-infer the parser based on given sample value.

Usage example:

import { inferFromSampleValue } from 'cast.ts'

let parser = inferFromSampleValue({
  postList: [
    {
      id: 1,
      title: 'Hello World',
      type$enums: ['public', 'vip'],
      hidden$optional: true,
    },
  ],
})
let input = parser.parse(req.body)
/* the type of parsed input is inferred as:
{
  postList: Array<{
    id: number
    title: string
    type: 'public' | 'vip'
    hidden?: boolean
  }>
}
*/

Supported field name decorators (suffix): $enums (alias: $enum), $nullable (alias: $null), $optional (alias: ?).

The field name decorators can be used in combination in any order.

Supported Parsers

Parser Types and Usage Examples

Utility type:

// to extract inferred type of parsed payload
type ParseResult<T extends Parser<R>, R = unknown> = ReturnType<T['parse']>

Reference types:

type Parser<T> = {
  parse(input: unknown, context?: ParserContext): T
  type: string // typescript signature of parsed value
  sampleValue: T
  randomSample: () => T
}

// used when building new data parser on top of existing parser
type ParserContext = {
  // e.g. array parser specify "array of <type>"
  typePrefix?: string
  // e.g. array parser specify "<reason> in array"
  reasonSuffix?: string
  // e.g. url parser specify "url" when calling string parser
  overrideType?: string
  // e.g. object parser specify entry key when calling entry value parser
  name?: string
}

For custom parsers:

If you want to implement custom parser you may reuse the InvalidInputError error class. The argument options is listed below:

class InvalidInputError extends Error {
  status: number // alias of statusCode
  statusCode: number // default 400
  constructor(options: InvalidInputErrorOptions) {
    let message = '...'
    super(message)
  }
}
type InvalidInputErrorOptions = {
  name: string | undefined
  typePrefix: string | undefined
  reasonSuffix: string | undefined
  expectedType: string
  reason: string
}

In addition, you may use the populateSampleProps helper function when constructing custom parser. The type signature is listed below:

function populateSampleProps<T>(options: {
  defaultProps: SampleProps<T>
  customProps?: CustomSampleOptions<T>
}): SampleProps<T>

type SampleProps<T> = {
  sampleValue: T
  randomSample: () => T
}

type CustomSampleOptions<T> = {
  sampleValue?: T
  sampleValues?: T[]
  randomSample?: () => T
}

String

Usage Example:

// keyword is a string potentially being empty
let keyword = string().parse(req.query.keyword)

// username is an non-empty string
let username = string({ minLength: 3, maxLength: 32 }).parse(req.body.username)

Options of string parser:

type StringOptions = {
  nonEmpty?: boolean
  minLength?: number
  maxLength?: number
  match?: RegExp
  trim?: boolean // default true
}

Number

Example:

// score is a non-NaN number
let score = number().parse(req.body.score)

// height is a non-negative number
let height = number({ min: 0 }).parse(req.body.height)

// stars is a non-NaN number
let stars = number({ readable: true }).parse('3.5k')

// tel is a number (85298765432)
let tel = number({ readable: true }).parse('852 9876-5432')

// amount is a number (123456)
let amount = number({ readable: true }).parse('123,456.00')

Options of number parser:

type NumberOptions = {
  min?: number
  max?: number
  /** @description turn `"3.5k"` into `3500` if enabled */
  readable?: boolean
  /** @example `"tr"` to treat `3,14` as `3.14` if `readable` is true */
  locale?: string
  /**
   * @description round `0.1 + 0.2` into `0.3` if enabled
   * @default true
   * */
  nearest?: boolean
}

Float

Example:

// degree is a real number (non-NaN, non-infinite)
let degree = float().parse(req.body.degree)

// height is a real number with at most 2 digits after the decimal point
let height = float({ toFixed: 2 }).parse(req.body.height)

// weight is a real number with at most 3 significant digits
let weight = float({ toPrecision: 2 }).parse(req.body.weight)

// score is a real number between 0 and 100 inclusively
let score = float({ min: 0, max: 100 })

Options of number parser:

type FloatOptions = NumberOptions & {
  toFixed?: number
  toPrecision?: number
}

Int

Usage Example:

// score is an integer between 1 to 5
let rating = int({ min: 1, max: 5 }).parse(req.body.rating)

Options of int parser: Same as NumberOptions

Id

Usage Example:

// cat_id is a non-zero integer
let cat_id = id().parse(req.query.cat)

The id parser doesn't take additional options

Boolean

It parse all truthy values as true, and falsy value as false with some exceptions to better support html form.

Example truthy value:

  • "on"
  • "true"
  • non-empty string (after trim)
  • non-zero numbers

Example falsy value:

  • "false"
  • 0
  • NaN
  • null
  • undefined
  • ""
  • " "
  • "\t"
  • "\r"
  • "\n"

Example:

// is_admin is a boolean value
let is_admin = boolean().parse(user.is_admin)

// is_cancelled will be false if product.cancel_time is null
let is_cancelled = boolean().parse(product.cancel_time)

// effectively asserting the user is admin (throw InvalidInputError if user.is_admin is falsy)
boolean(true).parse(user.is_admin)

Options of number parser:

function boolean(expectedValue?: boolean): Parser<boolean>

Checkbox

When this parser is used as a field of object parser, it will treat absent fields as falsy because browsers will omit unchecked fields when submitting form.

Example:

// is_admin is a boolean
let is_admin = checkbox().parse(req.body.is_admin)

The checkbox parser doesn't take additional options

Color

Example:

// primary_color is a string in "#rrggbb" format
let primary_color = color().parse(req.body.primary_color)

The color parser doesn't take additional options

Object

Example:

// newUser is an object of { username: string, email: string }
let newUser = object({
  username: string({ minLength: 3, maxLength: 32 }),
  email: email(),
}).parse(req.body)

Options of object parser:

type ObjectOptions<T extends object> = {
  [P in keyof T]: Parser<T[P]>
}

Date

Example:

// sinceDate is a Date object indicating a timestamp in the past
let sinceDate = date({ max: Date.now() }).parse(req.query.sinceDate)

// untilDate is a Date object between sinceDate and current timestamp
let untilDate = date({
  max: Date.now(),
  min: sinceDate,
}).parse(req.query.untilDate)

Options of date parser:

type DateOptions = {
  min?: number | Date | string
  max?: number | Date | string
}

DateString

Convert from string | Date | number to string in the format of yyyy-mm-dd

Example:

// sinceDate is date string indicating a date in the past
let sinceDate = dateString({ max: Date.now() }).parse(req.query.sinceDate)

// untilDate is a date string between sinceDate and current date
let untilDate = date({
  max: Date.now(),
  min: sinceDate,
}).parse(req.query.untilDate)

Options of dateString parser:

type DateStringOptions = {
  nonEmpty?: boolean
  min?: number | Date | string
  max?: number | Date | string
}

TimeString

Convert from string | Date | number to string in the format of hh:mm

Example:

// sinceTime is time string indicating a time in the past (same date)
let sinceTime = timeString({ max: Date.now() }).parse(req.query.sinceTime)

// untilTime is a time string between sinceTime and current time
let untilTime = time({
  max: Date.now(),
  min: sinceTime,
}).parse(req.query.untilTime)

Options of timeString parser:

type TimeStringOptions = {
  nonEmpty?: boolean
  min?: number | Date | string
  max?: number | Date | string
}

Url

Example:

// blogUrl is a string of hyperlink
let blogUrl = url({ protocols: ['https', 'http'] }).parse(req.body.blogUrl)

Options of url parser:

type UrlOptions = StringOptions & {
  domain?: string
  protocol?: string
  protocols?: string[]
}

Email

Example:

// userEmail is a string of email address
let userEmail = email().parse(req.body.email)

Options of url parser:

type EmailOptions = StringOptions & {
  domain?: string
}

Literal

Example:

// effectively asserting the role is 'guest' (throw InvalidInputError if it isn't)
let role = literal('guest').parse(req.session?.role)

Options of literal parser:

function literal<T>(value: T): Parser<T>

Values / Enums

Example:

// color is like an enums value of 'red' | 'yellow' | 'green' | 'blue'
let color = values([
  'red' as const,
  'yellow' as const,
  'green' as const,
  'blue' as const,
]).parse(req.query.color)

Options of values parser:

function values<T>(values: T[]): Parser<T>

// alias
let enums = values

The function values() is also aliased as enums()

Array

Example:

// categories is an array of string
let categories = array(string()).parse(req.body.categories)

// req.query is a string or array of string
// item_ids is an array of string
let item_ids = array(string(), { maybeSingle: true }).parse(req.query.item_id)

Options of array parser:

type ArrayOptions = {
  minLength?: number
  maxLength?: number
  maybeSingle?: boolean // to handle variadic value (e.g. req.query.category)
}

function array<T>(
  parser: Parser<T>,
  options: ArrayOptions & CustomSampleOptions<T[]> = {},
): Parser<T[]>

SingletonArray

Example:

// fields.title is a single-element array of string
// title is a string
let title = singletonArray(string()).parse(fields.title)

// example parsing result from formidable v3+
let formInput = object({
  fields: object({
    title: singletonArray(string()),
  }),
  files: object({
    cover_image: singletonArray(object({ newFilename: string() })),
  }),
}).parse({ fields, files })

Options of singletonArray parser:

function singletonArray<T>(valueParser: Parser<T>): Parser<T>

Nullable

Example:

// tag is a string or null value
let tag = nullable(string()).parse(req.body.tag)

Options of nullable parser:

function nullable<T>(parser: Parser<T>): Parser<T | null>

Optional

Example:

/** searchQuery is an object of {
 *   page?: number
 *   count?: number
 *   category?: string
 *   keyword: string
 * }
 */
let searchQuery = object({
  page: optional(int({ min: 1 })),
  count: optional(int({ max: 25 })),
  category: optional(string()),
  keyword: string({ minLength: 3 }),
}).parse(req.query)

Options of nullable parser:

function optional<T>(parser: Parser<T>): Parser<T | undefined>

Or / Union

Example:

// filter1.is_cancelled is `boolean | { $notNull: boolean } | { $null: boolean }`
let filter1 = object({
  is_cancelled: union([
    boolean(),
    object({ $notNull: boolean() }),
    object({ $null: boolean() }),
  ]),
}).parse(req.body)

// filter2.category is `false | Array<number>`
let filter2 = object({
  category: or([literal(false), array(id())]),
})

Options of or/union parser:

function or<T>(
  parsers: Parser<T>[],
  options: CustomSampleOptions<T> = {},
): Parser<T>

// alias
let union = or

Dict / Record

Example:

const fieldNameParser = values(['create_time', 'update_time'])
const sortTypeParser = values(['asc', 'desc'])
const queryParser = object({
  sort: dict({ key: fieldNameParser, value: sortTypeParser }),
})
// query.sort is `Record<"create_time" | "update_time", "asc" | "desc">`
let query = queryParser.parse(req.body)

Options of dict/record parser:

function dict<K extends PropertyKey, V>(
  options: {
    key?: Parser<K>
    value: Parser<V>
  } & CustomSampleOptions<Record<K, V>>,
): Parser<Record<K, V>>

// alias
let record = dict

Acknowledgments

The API design is inspired by Zod. The main difference is cast.ts auto convert data between different types with it's valid, e.g. it converts numeric value from string if it's valid, which is useful when parsing data from req.query.

The icon of cast.ts is generated with diffuse-the-rest and up-scaled by Real-ESRGAN, then it is post-processed with GIMP.

License

This project is licensed with BSD-2-Clause

This is free, libre, and open-source software. It comes down to four essential freedoms [ref]:

  • The freedom to run the program as you wish, for any purpose
  • The freedom to study how the program works, and change it so it does your computing as you wish
  • The freedom to redistribute copies so you can help others
  • The freedom to distribute copies of your modified versions to others

About

Compose validation parsers with static type inference, makes it easy to handle data from RESTful API

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published