Skip to content

Commit

Permalink
feat: add nonEmptyArray and fix type inferrence for array() (#10)
Browse files Browse the repository at this point in the history
## What?

-  add `nonEmptyArray` and `isNonEmptyArray`
- fix type inference for `array()`, which prior to this PR inferred the
type as `unknown[]`
  • Loading branch information
johannes-lindgren authored Apr 8, 2024
1 parent 27d01d0 commit a55efb9
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 13 deletions.
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Then there is a second category of higher order functions that construct new, cu
- `record`—for [records](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type) with a finite amount of keys; e.g. `Record<'left' | 'right' | 'top' | 'bottom', number>`
- `partialRecord`—for [records](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type) where not all values are defined; e.g. `Partial<Record<string, number>>`
- `array`—for [arrays](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#arrays), e.g. `string[]`
- `nonEmptyArrays`—for arrays with at least one item; for example `[string, ...string[]]`
By composing these higher order functions and primitives, you end up with a schema-like syntax that models your data:
Expand Down Expand Up @@ -250,6 +251,15 @@ isOptionalNullableString(undefined) // -> true
isOptionalNullableString(null) // -> true
```

When explicitly declaring union types, provide a tuple of the union members as type argument:

```ts
const isId = union<['string', 'number']>(isString, isNumber)
const isColor = literal<['red', 'green', 'blue']>('red', 'green', 'blue')
```

Due to a limitation of TypeScript, you can't' write `union<string | number>()` or `literal<'red' | 'green' | 'blue'>()`. Therefore, it is generally recommended to omit the type arguments for union types and let TypeScript infer them.

### Tuples

Tuples are arrays of fixed length, where each element has a specific type. Use the `tuple()` function to create a validation function for a tuple type:
Expand Down Expand Up @@ -304,6 +314,19 @@ const isUser = object({
isUser({ id: 42 }) // -> true
```

You can explicitly declare the type of the object and annotate the validation function with the type as a type parameter:

```ts
type User = {
id: number
name?: string
}
const isUser = object<User>({
id: isNumber,
name: optional(isString),
})
```

### Records

Records are objects that map string keys to values. Call `record()` with a list of all keys and a validation function for the values:
Expand Down Expand Up @@ -350,6 +373,34 @@ const isDna = array(isBase)
isDna(['A', 'T', 'A', 'T', 'C', 'G']) // -> true
```

Sometimes, it's useful to know whether an array has at least one element. Use the `nonEmptyArray()` function to create a validation function for an array with at least one element:

```ts
import { nonEmptyArray } from './validation'

const isToggleState = nonEmptyArray(literal('on', 'off', 'indeterminate'))
```

Both of these functions check every element in the array. If you already have an array of validated data, and you want to find out wether it is non-empty, you can use the `nonEmptyArray` function:

```ts
import { isNonEmptyArray } from './validation'
;(names: string[]) => {
if (isNonEmptyArray(names)) {
console.log(names[0]) // -> string
}
}
```

When explicitly declaring array types, provide type of the item in the array type argument:

```ts
// Validator<number[]>
const isNumberArray = array<number>(isNumber)
// Validator<[T, ...T[]][]>
const isNonEmptyNumberArray = nonEmptyArray<number>(isNumber)
```

### Tagged/Discriminated Unions

Validate discriminated unions with unions of objects with a common tag property:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pure-parse",
"version": "0.0.0-beta.1",
"version": "0.0.0-beta.2",
"private": false,
"description": "Minimalistic validation library with 100% type inference",
"author": {
Expand Down
116 changes: 109 additions & 7 deletions src/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
Validator,
Infer,
record,
nonEmptyArray,
isNonEmptyArray,
} from './validation'

export type Equals<T1, T2> = T1 extends T2
Expand Down Expand Up @@ -924,22 +926,36 @@ describe('validation', () => {
})
describe('recursive types', () => {
describe('isArray', () => {
describe('type checking', () => {
describe('types', () => {
it('returns a validator', () => {
array(isString) satisfies Validator<string[]>
// @ts-expect-error
array(isString) satisfies Validator<number[]>
})
it('infers the exact type', () => {
// Number
const isNumberArray = array((d): d is number => true)
type NumberArray = Infer<typeof isNumberArray>
const assertionNumber1: Equals<NumberArray, number[]> = true
const assertionNumber2: Equals<NumberArray, unknown[]> = false
// String
const isStringArray = array(isString)
type StringArray = Infer<typeof isStringArray>
const assertionString1: Equals<StringArray, string[]> = true
const assertionString2: Equals<StringArray, unknown[]> = false
})
test('explicit generic type annotation', () => {
array<number[]>(isNumber)
array<string[]>(isString)
array<(string | number)[]>(union(isString, isNumber))
array<number>(isNumber)
array<string>(isString)
array<string | number>(union(isString, isNumber))
// @ts-expect-error
array<number[]>(union(isString, isNumber))
array<number>(union(isString, isNumber))
// @ts-expect-error
array<string[]>(isNumber)
array<string>(isNumber)
// @ts-expect-error
array<string[]>(isString)
// @ts-expect-error
array<string[][]>(array(isNumber))
array<string>(array(isNumber))
})
})
it('validates null', () => {
Expand Down Expand Up @@ -992,6 +1008,92 @@ describe('validation', () => {
).toEqual(true)
})
})
describe('non-empty arrays', () => {
describe('isNonEmptyArray', () => {
describe('type', () => {
it('infers the type', () => {
const numberArray: number[] = [1, 2, 3]
if (isNonEmptyArray(numberArray)) {
const assertionKnownArrayType4: Equals<
typeof numberArray,
[number, ...number[]]
> = true
}
})
})
it('validates non-empty arrays', () => {
expect(isNonEmptyArray([1])).toEqual(true)
expect(isNonEmptyArray([1, 2, 3])).toEqual(true)
})
it('invalidates empty arrays', () => {
expect(isNonEmptyArray([])).toEqual(false)
})
})
describe('nonEmptyArray', () => {
describe('type', () => {
it('infers the exact type', () => {
// Number
const isNumberArray = nonEmptyArray(isNumber)
type NumberArray = Infer<typeof isNumberArray>
const assertionNumber1: Equals<
NumberArray,
[number, ...number[]]
> = true
const assertionNumber2: Equals<NumberArray, number[]> = false
const assertionNumber3: Equals<NumberArray, unknown[]> = false
const assertionNumber4: Equals<
NumberArray,
[unknown, ...unknown[]]
> = false
// String
const isStringArray = nonEmptyArray(isString)
type StringArray = Infer<typeof isStringArray>
const assertionString1: Equals<
StringArray,
[string, ...string[]]
> = true
const assertionString2: Equals<StringArray, string[]> = false
const assertionString3: Equals<StringArray, unknown[]> = false
const assertionString4: Equals<
StringArray,
[unknown, ...unknown[]]
> = false
})
})
it('validates nonempty arrays', () => {
expect(nonEmptyArray(isUnknown)([1])).toEqual(true)
expect(nonEmptyArray(isUnknown)([1, 2, 3])).toEqual(true)
})
it('invalidates empty arrays', () => {
expect(nonEmptyArray(isUnknown)([])).toEqual(false)
})
it('invalidates non-arrays', () => {
expect(nonEmptyArray(isUnknown)({})).toEqual(false)
})
it('validates each item in the array', () => {
expect(nonEmptyArray(isNumber)([1, 2])).toEqual(true)
expect(nonEmptyArray(isNumber)(['a', 'b'])).toEqual(false)
})
})
})
})
})
describe('Infer', () => {
it('infers the type', () => {
const isUser = object({
id: isNumber,
uid: isString,
active: isBoolean,
})
type User = Infer<typeof isUser>
const assertion1: Equals<
User,
{ id: number; uid: string; active: boolean }
> = true
const assertion2: Equals<
User,
{ id: string; uid: string; active: boolean }
> = false
})
})
describe('Infer', () => {
Expand Down
28 changes: 23 additions & 5 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ export type Validator<T> = (data: unknown) => data is T
*/
export type Infer<
T extends (data: unknown, ...args: unknown[]) => data is unknown,
> = T extends (data: unknown, ...args: unknown[]) => data is infer R
? R
: unknown
> = T extends (data: unknown, ...args: unknown[]) => data is infer R ? R : never

/**
* Use to skip validation, as it returns true for any input.
Expand Down Expand Up @@ -228,9 +226,29 @@ export const partialRecord =
*/

/**
* Validate arrays
* @param validateItem validates every item in the array
* @return a validator function that validates arrays
*/
export const array =
<T extends unknown[]>(validateItem: Validator<T[number]>): Validator<T> =>
(data: unknown): data is T =>
<T>(validateItem: Validator<T>): Validator<T[]> =>
(data: unknown): data is T[] =>
Array.isArray(data) && data.every(validateItem)

/**
* Validate non-empty arrays
* @param validateItem validates every item in the array
* @return a validator function that validates non-empty arrays
*/
export const nonEmptyArray =
<T>(validateItem: Validator<T>): Validator<[T, ...T[]]> =>
(data: unknown): data is [T, ...T[]] =>
Array.isArray(data) && data.length !== 0 && data.every(validateItem)

/**
* Use this when the data that you want to validate is already a known array
* @param data an array
* @return `true` if data has at least one element
*/
export const isNonEmptyArray = <T>(data: T[]): data is [T, ...T[]] =>
data.length !== 0

0 comments on commit a55efb9

Please sign in to comment.