Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typed error #634

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,49 @@
* A `StructFailure` represents a single specific failure in validation.
*/

export type Failure = {
export type Failure<E extends Error> = {
value: any
key: any
type: string
refinement: string | undefined
message: string
branch: Array<any>
path: Array<any>
detail: E
failures?: Failure<E>[]
}

export type Error = ErrorDetail | never
export interface ErrorDetail {
class: string
message?: string
}
export interface GenericErrorDetail extends ErrorDetail {
class: 'generic'
message: string
}

export interface TypeErrorDetail extends ErrorDetail {
class: 'type'
except: string
actually: unknown
}

export interface ValuesErrorDetail<T> extends ErrorDetail {
class: 'values'
except: T[]
actually: T
}

export interface ValueErrorDetail<T> extends ErrorDetail {
class: 'value'
except: T
actually: unknown
}

export interface ThrowErrorDetail extends ErrorDetail {
class: 'throw'
error: any
}

/**
Expand All @@ -21,24 +56,26 @@ export type Failure = {
* continue validation and receive all the failures in the data.
*/

export class StructError extends TypeError {
export class StructError<E extends Error> extends TypeError {
value: any
key!: any
type!: string
refinement!: string | undefined
path!: Array<any>
branch!: Array<any>
failures: () => Array<Failure>;
failures: () => Array<Failure<E>>
detail: E;
[x: string]: any

constructor(failure: Failure, failures: () => Generator<Failure>) {
let cached: Array<Failure> | undefined
constructor(failure: Failure<E>, failures: () => Generator<Failure<E>>) {
let cached: Array<Failure<E>> | undefined
const { message, ...rest } = failure
const { path } = failure
const { path, detail } = failure
const msg =
path.length === 0 ? message : `At path: ${path.join('.')} -- ${message}`
super(msg)
Object.assign(this, rest)
this.detail = detail
this.name = this.constructor.name
this.failures = () => {
return (cached ??= [failure, ...failures()])
Expand Down
100 changes: 73 additions & 27 deletions src/struct.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
import { toFailures, shiftIterator, StructSchema, run } from './utils'
import { StructError, Failure } from './error'
import {
StructError,
Failure,
Error,
ErrorDetail,
ThrowErrorDetail,
} from './error'
import { masked } from './structs/coercions'

/**
* `Struct` objects encapsulate the validation logic for a specific type of
* values. Once constructed, you use the `assert`, `is` or `validate` helpers to
* validate unknown input data against the struct.
*/

export class Struct<T = unknown, S = unknown> {
export class Struct<T = unknown, S = unknown, E extends Error = never> {
readonly TYPE!: T
type: string
schema: S
coercer: (value: unknown, context: Context) => unknown
validator: (value: unknown, context: Context) => Iterable<Failure>
refiner: (value: T, context: Context) => Iterable<Failure>
validator: (value: unknown, context: Context) => Iterable<Failure<E>>
refiner: (value: T, context: Context) => Iterable<Failure<E>>
entries: (
value: unknown,
context: Context
) => Iterable<[string | number, unknown, Struct<any> | Struct<never>]>
) => Iterable<
[
string | number,
unknown,
Struct<any, unknown, E> | Struct<never, unknown, E>
]
>

constructor(props: {
type: string
schema: S
coercer?: Coercer
validator?: Validator
refiner?: Refiner<T>
entries?: Struct<T, S>['entries']
validator?: Validator<E>
refiner?: Refiner<T, E>
entries?: Struct<T, S, E>['entries']
}) {
const {
type,
Expand All @@ -45,6 +56,7 @@ export class Struct<T = unknown, S = unknown> {
if (validator) {
this.validator = (value, context) => {
const result = validator(value, context)
if (result === true || result === undefined) return []
return toFailures(result, context, this, value)
}
} else {
Expand All @@ -54,6 +66,7 @@ export class Struct<T = unknown, S = unknown> {
if (refiner) {
this.refiner = (value, context) => {
const result = refiner(value, context)
if (result === true || result === undefined) return []
return toFailures(result, context, this, value)
}
} else {
Expand Down Expand Up @@ -108,7 +121,7 @@ export class Struct<T = unknown, S = unknown> {
options: {
coerce?: boolean
} = {}
): [StructError, undefined] | [undefined, T] {
): [StructError<E | ThrowErrorDetail>, undefined] | [undefined, T] {
return validate(value, this, options)
}
}
Expand All @@ -117,9 +130,9 @@ export class Struct<T = unknown, S = unknown> {
* Assert that a value passes a struct, throwing if it doesn't.
*/

export function assert<T, S>(
export function assert<T, S, E extends Error>(
value: unknown,
struct: Struct<T, S>
struct: Struct<T, S, E>
): asserts value is T {
const result = validate(value, struct)

Expand All @@ -132,7 +145,10 @@ export function assert<T, S>(
* Create a value with the coercion logic of struct and validate it.
*/

export function create<T, S>(value: unknown, struct: Struct<T, S>): T {
export function create<T, S, E extends Error>(
value: unknown,
struct: Struct<T, S, E>
): T {
const result = validate(value, struct, { coerce: true })

if (result[0]) {
Expand All @@ -146,7 +162,10 @@ export function create<T, S>(value: unknown, struct: Struct<T, S>): T {
* Mask a value, returning only the subset of properties defined by a struct.
*/

export function mask<T, S>(value: unknown, struct: Struct<T, S>): T {
export function mask<T, S, E extends Error>(
value: unknown,
struct: Struct<T, S, E>
): T {
const M = masked(struct)
const ret = create(value, M)
return ret
Expand All @@ -156,7 +175,10 @@ export function mask<T, S>(value: unknown, struct: Struct<T, S>): T {
* Check if a value passes a struct.
*/

export function is<T, S>(value: unknown, struct: Struct<T, S>): value is T {
export function is<T, S, E extends Error>(
value: unknown,
struct: Struct<T, S, E>
): value is T {
const result = validate(value, struct)
return !result[0]
}
Expand All @@ -166,13 +188,13 @@ export function is<T, S>(value: unknown, struct: Struct<T, S>): value is T {
* value (with potential coercion) if valid.
*/

export function validate<T, S>(
export function validate<T, S, E extends Error>(
value: unknown,
struct: Struct<T, S>,
struct: Struct<T, S, E>,
options: {
coerce?: boolean
} = {}
): [StructError, undefined] | [undefined, T] {
): [StructError<E | ThrowErrorDetail>, undefined] | [undefined, T] {
const tuples = run(value, struct, options)
const tuple = shiftIterator(tuples)!

Expand Down Expand Up @@ -206,23 +228,31 @@ export type Context = {
* A type utility to extract the type from a `Struct` class.
*/

export type Infer<T extends Struct<any, any>> = T['TYPE']
export type Infer<T extends Struct<any, any, any>> = T['TYPE']

/**
* A type utility to describe that a struct represents a TypeScript type.
*/

export type Describe<T> = Struct<T, StructSchema<T>>
export type Describe<T> = Struct<T, StructSchema<T>, any> // todo

export type InferError<T> = T extends Struct<any, any, infer E> ? E : Error

/**
* A `Result` is returned from validation functions.
*/

export type Result =
| boolean
| string
| Partial<Failure>
| Iterable<boolean | string | Partial<Failure>>
export type Result<E extends ErrorDetail> =
// | boolean
// | string
// | Partial<Failure<E>>
// | Iterable<boolean | string | Partial<Failure<E>>>
// undefined | Iterable<Failure<E>> | Failure<E>
/* BasicResult |*/ | DescribedResult<E>
| Iterable</* BasicResult | */ DescribedResult<E>>

// export type BasicResult = boolean | string;
export type DescribedResult<E extends ErrorDetail> = E | Failure<E>

/**
* A `Coercer` takes an unknown value and optionally coerces it.
Expand All @@ -234,11 +264,27 @@ export type Coercer<T = unknown> = (value: T, context: Context) => unknown
* A `Validator` takes an unknown value and validates it.
*/

export type Validator = (value: unknown, context: Context) => Result
export type Validator<E extends ErrorDetail> = (
value: unknown,
context: Context
) => Iterable<E | Failure<E>> | true

export type SimpleValidator = (
value: unknown,
context: Context
) => string | boolean | undefined

/**
* A `Refiner` takes a value of a known type and validates it against a further
* constraint.
*/

export type Refiner<T> = (value: T, context: Context) => Result
export type Refiner<T, E extends ErrorDetail> = (
value: T,
context: Context
) => Iterable<E | Failure<E>> | true

export type SimpleRefiner<T> = (
value: T,
context: Context
) => string | boolean | undefined
25 changes: 15 additions & 10 deletions src/structs/coercions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Error, TypeErrorDetail } from '../error'
import { Struct, is, Coercer } from '../struct'
import { isPlainObject } from '../utils'
import { string, unknown } from './types'
Expand All @@ -13,12 +14,12 @@ import { string, unknown } from './types'
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function coerce<T, S, C>(
struct: Struct<T, S>,
condition: Struct<C, any>,
export function coerce<T, S, C, E1 extends Error, E2 extends Error>(
struct: Struct<T, S, E1>,
condition: Struct<C, any, E2>,
coercer: Coercer<C>
): Struct<T, S> {
return new Struct({
): Struct<T, S, E1 | E2> {
return new Struct<T, S, E1 | E2>({
...struct,
coercer: (value, ctx) => {
return is(value, condition)
Expand All @@ -35,13 +36,13 @@ export function coerce<T, S, C>(
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function defaulted<T, S>(
struct: Struct<T, S>,
export function defaulted<T, S, E extends Error>(
struct: Struct<T, S, E>,
fallback: any,
options: {
strict?: boolean
} = {}
): Struct<T, S> {
): Struct<T, S, E> {
return coerce(struct, unknown(), (x) => {
const f = typeof fallback === 'function' ? fallback() : fallback

Expand Down Expand Up @@ -76,7 +77,9 @@ export function defaulted<T, S>(
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
export function masked<T, S, E extends Error>(
struct: Struct<T, S, E>
): Struct<T, S, E> {
return coerce(struct, unknown(), (x) => {
if (
typeof struct.schema !== 'object' ||
Expand Down Expand Up @@ -106,6 +109,8 @@ export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function trimmed<T, S>(struct: Struct<T, S>): Struct<T, S> {
export function trimmed<T, S, E extends Error>(
struct: Struct<T, S, E>
): Struct<T, S, E | TypeErrorDetail> {
return coerce(struct, string(), (x) => x.trim())
}
Loading