Skip to content

code-star/nope

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

57 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nope

Goals

  • To be a type-safe alternative to Yup
  • Composable validation (based on concepts from functional programming)
  • Completely type-safe
  • Custom error types

Introduction

Simple validation

Let's say we want to verify that a number is positive. We can define the following validation rule:

const isPositive = ValidationRule.test((n: number) => {
  return n >= 0 
    ? Validated.ok() 
    : Validated.error(`${n} is negative`)
})

This results in a ValidationRule<number, string>. The input is a number (the first type parameter), and validation might result in a error of the type string (the second type parameter).

How do we use it?

const validated = isPositive.apply(-4)

if (validated.isValid()) {
  console.log(`${validated.value} is positive!`)
} else {
  console.error(`Error: ${validated.error}`)
}

Transformations

Let's suppose we want to make sure that a string represents a valid number:

const isFloat = ValidationRule.test((s: string) => {
  const n = Number.parseFloat(s)
  return isNaN(n) 
    ? Validated.error(`${s} is not a number`) 
    : Validated.ok()
})

That's cool, but there's something inefficient about this. We do all this work to parse the string as a valid number, only to throw that number (the fruit of our labour) away once we've verified that it is a number. It is likely that we might want to use that number at some later point, and it feels inefficient to parse it twice (once to verify that it is a number, and afterwards again to actually be able to use it). To this end, we can create a validation rule with a return a value with ValidationRule.create:

const isFloat = ValidationRule.create((s: string) => {
  const n = Number.parseFloat(s)
  return isNaN(n) 
    ? Validated.error(`${s} is not a number`) 
    : Validated.ok(n)
})

The type of isFloat is ValidationRule<string, string, number>. The input is a string (the first type parameter) which we try to parse, and validation might result in a error of the type string (the second type parameter). The output of this validation rule is number (the third type parameter).

How do we use it?

const validated = isFloat.apply('123.456789')

if (validated.isValid()) {
  console.log(`${validated.value.toFixed(2)} is a number!`)
} else {
  console.error(`Error: ${validated.error}`)
}

Note the call to toFixed, which we are only able to do because value is a number.

The return value of a validation rule is where Nope differs from Yup. In Yup, the validation (is something valid or not) and the transformation to a valid value are two separate steps. In Nope, this is a single step. This makes it easy to create validation rules that build upon each other, as we'll see next.

Chaining

We can combine the two rules into one:

const isPositiveFloat = isFloat.composeWith(isPositive)

This validation rule takes a string and tries to parse it as a number. If it succeeds, it will verify that the number is positive. We can get one of two errors:

  • An error stating that the string does not contain a number. For example: 'Dog is not a number'
  • An error stating that the number is negative. For example: '-123.4 is negative

There are many ways to combine simple validation rules into more complex validation rules. Take a look at the documentation for combine, test or many for example.

Meta data

It's not uncommon to need some meta data to properly validate your data. Take the example where we want to verify that a given Date is in the past. We need to know the current time to be able to do this:

const isInPast = ValidationRule.test((date: Date, now: Date) => {
  return date <= now
    ? Validated.ok()
    : Validated.error(`${date} is after current time (${now})`)
})

This results in a ValidationRule<Date, string, Date, [Date]>. The input is a Date (the first type parameter), and validation might result in a error of the type string (the second type parameter). Because we are using ValidationRule.test (and not ValidationRule.create), validation will result in a Date (the third type parameter) which is the original Date we pass in. Lastly, the meta data is specified by the parameter list [Date] (the fourth type parameter). We can pass as many values in as we'd like.

How do we use it?

const date = new Date(2019, 4, 3, 14, 12)
const now = new Date(2019, 4, 3, 23, 59)
const validated = isInPast.apply(date, now)

if (validated.isValid()) {
  console.log(`${date} is in the past!`)
} else {
  console.error(`Error: ${validated.error}`)
}

Error types

One of the goals of this library is to properly track all the possible errors, so you can be sure you handle all of them (and not too many). When we want to verify that a value (of type unknown) is a string containing a positive number we can define the following validation rule:

const containsPositiveNumber = Strings
  .fromUnknown()
  .composeWith(Strings.containsFloat())
  .composeWith(Numbers.positive())

the type of this validation rule is ValidationRule<unknown, NotAString | DoesNotContainFloat | NotPositive, number>. Contrast this with an error type like Yup's, where every error is encoded as a string.

API

Note that this documentation is not yet complete. Explore the API to learn more about what is possible. Help making the documentation complete is very welcome.

ValidationRule.combine

Creates a ValidationRule for an object. Define the keys of the object and the validation rules for those keys.

const isPerson = ValidationRule.combine({
  age: Numbers.fromNumber().composeWith(Numbers.positive()),
  name: Strings.fromString().composeWith(Strings.notEmpty())
})

Using this validation rule results in a valid object of shape

{
  age: number,
  name: string
}

or an error of shape

{
  age?: NotPositive,
  name?: EmptyString
}

ValidationRule.composeWith

Compose two validation rules. Produces either of the two errors of the individual validation rules.

For example,

Numbers.fromUnknown().composeWith(Numbers.positive())

will produce either a NotANumber error or a NotPositive error when it fails.

You can chain as many composeWith-calls as you like. The following will check whether an unknown value is a string which contains a positive float:

const containsPositiveFloat = Strings.fromUnknown()
  .composeWith(Strings.notEmpty())
  .composeWith(Strings.containsFloat())
  .composeWith(Numbers.positive())

This validation rule can result in either:

  • A NotAString error
  • An EmptyString error
  • A DoesNotContainFloat error
  • A NotPositive error

ValidationRule.many

From a ValidationRule for a type A, creates a ValidationRule for type A[].

Contrast with ValidationRule.of.

const areAllPositive = Numbers.positive().many()

Using this validation rule results in a valid Array<number> when all input values are positive, or an error of shape Array<NotPositive | undefined>. The error Array will only have values at the indices where the negative numbers are located.

ValidationRule.of

Given that the output type of validation rule is A[], allows you to apply a validation rule that takes an A as input.

Contrast with ValidationRule.many.

const areAllPositive = Arrays.fromArray<number>().of(Numbers.positive())

ValidationRule.test

Combining validation rules like composeWith produces a union of errors. We can expect either an error of this shape, or an error of that shape. We can never get both errors at the same time.

ValidationRule.test allows you to run multiple validation rules at the same time (producing possibly many errors). Those validation rules are used purely for their errors. The values returned from the individual validation rules are discarded.

ValidationRule.optional

Allows optional values (but doesn't raise an error). Mostly used to wrap a composeWith-chain to make the whole chain optional.

Contrast this with ValidationRule.required.

const isNumber = Numbers
  .fromNumber()
  .composeWith(Numbers.positive())
  .optional()

// Results in `Validated.ok(undefined)`.
isNumber.apply(undefined) // OK!

ValidationRule.required

Allows optional inputs, but raises an error if the input is undefined.

Contrast this with ValidationRule.optional.

const isNumber = Numbers
  .fromNumber()
  .composeWith(Numbers.positive())
  .required()

// Results in `IsUndefined` error but is allowed by
// the type system.
isNumber.apply(undefined)

ValidationRule.map

Apply a transformation function to the result of the validation rule.

ValidationRule.orElse

Return the value if it is valid, "or else" fall back to the given default.

ValidationRule.mapError

Transforms the error value, like ValidationRule.map transforms the valid value.

Booleans.fromBoolean

ValidationRule that takes a boolean and produces a boolean. This rule will not produce any errors. It is usually used as a starting point for more complex validation rules.

Booleans.fromUnknown

ValidationRule that ensures that a given value (of type unknown) is a boolean. May produce a NotABoolean error.

Numbers.fromNumber

ValidationRule that takes a number and produces a number. This rule will not produce any errors. It is usually used as a starting point for more complex validation rules.

Numbers.fromUnknown

ValidationRule that ensures that a given value (of type unknown) is a number. May produce a NotANumber error.

Numbers.positive

ValidationRule that ensures that a given number is positive. May produce a NotPositive error.

Strings.fromString

ValidationRule that takes a string and produces a string. This rule will not produce any errors. It is usually used as a starting point for more complex validation rules.

Strings.fromUnknown

ValidationRule that ensures that a given value (of type unknown) is a string. May produce a NotAString error.

Strings.notEmpty

Verifies that a given string is not empty.

Strings.containsFloat

Verifies that a given string contains a number. Uses parseFloat under the hood. Produces a (possible) number.

Arrays.fromArray

ValidationRule that takes an array and produces an array. Takes a type parameter to limit the type of acceptable elements. This rule will not produce any errors. It is usually used as a starting point for more complex validation rules.