codefeathers / runtype

Runtime type assertions that return
https://codefeathers.github.io/runtype
5 stars 0 forks source link

Proposal: API for assertion messages #1

Open MKRhere opened 4 years ago

MKRhere commented 4 years ago

As we know, runtype only tells you if an type-check passed or not, and doesn't tell why it failed. This proposal outlines the new API for validation messages.

Description

Currently, Predicate is a function that takes x and guards it against a type. This proposal recommends the addition of a validate method to Predicate which returns the following type:

{ ok: true, value: T } | { ok: false, error: ValidationError }

Where ValidationError extends Error with the properties expected and actual describing the corresponding types as string to produce a nicer error; and by default .message describing a generated error.

Example:

const Message = struct({ from: string, to: string, content: string });

// elsewhere
const res = Message.validate(msg);

if (res.ok) {
  // use msg
} else {
  // res.error
}

Downsides:

wojpawlik commented 4 years ago

While res.ok doesn't guard msg, it does guard res.value.

I'd add cata method to the type returned by validate, for easy conversion to more sophisticated types, and for "inline" branching, without introducing a constant like res:

// result.ts
interface CataOk<OkIn, OkOut> {
    Ok(value: OkIn): OkOut
}
interface CataErr<ErrIn, ErrOut> {
    Err(err: ErrIn): ErrOut
}
type Cata<OkIn, OkOut, ErrIn, ErrOut> = CataOk<OkIn, OkOut> & CataErr<ErrIn, ErrOut>

interface BaseResult<T, E> {
    readonly ok: boolean
    cata<OkOut, ErrOut>(cata: Cata<T, OkOut, E, ErrOut>): OkOut | ErrOut
}
export class Ok<T> implements BaseResult<T, never> {
    readonly ok = true as const
    constructor(readonly value: T) {}
    cata<OkOut>(cata: CataOk<T, OkOut>) {
        return cata.Ok(this.value)
    }
}
export class Err<E> implements BaseResult<never, E> {
    readonly ok = false as const
    constructor(readonly error: E) {}
    cata<ErrOut>(cata: CataErr<E, ErrOut>) {
        return cata.Err(this.error)
    }
}

export type Result<T, E> = BaseResult<T, E> & (Ok<T> | Err<E>)

Playground

  1. ValidationError might be confused with Err, perhaps they should be merged?
  2. Will ValidationError be subclassed for representing different kinds of validation errors?
  3. Not every validator should be a predicate (positiveNumber).
  4. When TypeScript can tell that validation will result in Ok, should it require or disallow Err "clause"?
trgwii commented 4 years ago
type AnyFn = (...args: any[]) => any;

interface Cata<Ok extends AnyFn, Err extends AnyFn> {
    Ok: Ok,
    Err: Err
}

class Ok<Value> {
    constructor(private value: Value) {}
    cata<
        Ok extends (value: Value) => any,
        Err extends AnyFn
    >(x: Cata<Ok, Err>): ReturnType<Ok> {
        return x.Ok(this.value);
    }
}

class Err<Error> {
    constructor(private error: Error) {}
    cata<
        Ok extends AnyFn,
        Err extends (error: Error) => any
    >(x: Cata<Ok, Err>): ReturnType<Err> {
        return x.Err(this.error);
    }
}

export class Result<Value, Error> {
    static Ok<Value, Error = never>(value: Value) {
        return new Result<Value, Error>(new Ok(value));
    }
    static Err<Error, Value = never>(error: Error) {
        return new Result<Value, Error>(new Err(error));
    }
    constructor(private result: Ok<Value> | Err<Error>) {}
    cata<
        Ok extends (value: Value) => any,
        Err extends (error: Error) => any
    >(x: Cata<Ok, Err>): ReturnType<Ok> | ReturnType<Err> {
        return (this.result.cata as AnyFn)(x);
    }
    map<
        NextValue
    >(mapper: (value: Value) => NextValue): Result<NextValue, Error> {
        return this.bimap<NextValue, Error>(mapper, <T>(x: T) => x);
    }
    mapErr<
        NextError
    >(mapper: (error: Error) => NextError): Result<Value, NextError> {
        return this.bimap<Value, NextError>(<T>(x: T) => x, mapper);
    }
    bimap<
        NextValue,
        NextError
    >(
        mapValue: (value: Value) => NextValue,
        mapError: (error: Error) => NextError
    ): Result<NextValue, NextError> {
        return new Result((this.result.cata as AnyFn)({
            Ok: (value: Value) => new Ok(mapValue(value)),
            Err: (error: Error) => new Err(mapError(error))
        }));
    }
}

Here's an alternative implementation of Result

wojpawlik commented 4 years ago

With @trgwii's suggestions:

type AnyFn = (x: never) => unknown;

interface Cata<OkFn extends AnyFn, ErrFn extends AnyFn> {
    Ok: OkFn
    Err: ErrFn
}

type CataOk<OkFn extends AnyFn> = Cata<OkFn, AnyFn>;
type CataErr<ErrFn extends AnyFn> = Cata<AnyFn, ErrFn>;

interface BaseResult<T, E> {
    readonly ok: boolean
    cata<Value, Error>(cata: Cata<(x: T) => Value, (x: E) => Error>): Value | Error
}

export namespace Result {
    export class Ok<T> implements BaseResult<T, never> {
        readonly ok = true as const;
        constructor(readonly value: T) { }
        cata<Value>(cata: CataOk<(value: T) => Value>) {
            return cata.Ok(this.value);
        }
    }
    export class Err<E> implements BaseResult<never, E> {
        readonly ok = false as const;
        constructor(readonly error: E) { }
        cata<Error>(cata: CataErr<(error: E) => Error>) {
            return cata.Err(this.error);
        }
    }
}

export type Result<T, E> = BaseResult<T, E> & (Result.Ok<T> | Result.Err<E>);