diff --git a/index.ts b/index.ts index ad99159..3f9d52b 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,10 @@ -export * from "./lib/result"; +export { + // Because lib/result has a bunch of pretty-printing helpers and such, + // we only export specific thigns publically + Result, + Err, + StructuralError, +} from "./lib/result"; export * from "./lib/type"; export * from "./lib/get-type"; export * from "./lib/match" diff --git a/lib/checks/any.ts b/lib/checks/any.ts index b31eb3f..12a516d 100644 --- a/lib/checks/any.ts +++ b/lib/checks/any.ts @@ -5,6 +5,10 @@ export class Any extends Type { check(val: any): Result { return val; } + + toString() { + return 'any' + } } export const any = new Any(); diff --git a/lib/checks/array.ts b/lib/checks/array.ts index 59f8566..027c109 100644 --- a/lib/checks/array.ts +++ b/lib/checks/array.ts @@ -10,17 +10,27 @@ export class Arr extends Type> { } check(val: any): Result> { - if(!Array.isArray(val)) return new Err(`${val} is not an array`); + if(!Array.isArray(val)) return this.err(`not an array`, val) - for(const el of val) { - const result = this.elementType.check(el); - // Don't bother collecting all errors in an array: for long arrays this is very obnoxious - if(result instanceof Err) return new Err(result.message); + + // use traditional iteration so we know the index + for (let i = 0; i return val as Array; } + + toString() { + return `Array<${this.elementType}>` + } } export function array(t: Type): Arr { diff --git a/lib/checks/dict.ts b/lib/checks/dict.ts index 8df725d..60b0d1f 100644 --- a/lib/checks/dict.ts +++ b/lib/checks/dict.ts @@ -13,17 +13,21 @@ export class Dict extends Type> { } check(val: any): Result> { - if(typeof val !== 'object') return new Err(`${val} is not an object`); - if(Array.isArray(val)) return new Err(`${val} is an array`); - if(val === null) return new Err(`${val} is null`); + if(typeof val !== 'object') return this.err(`not an object`, val); + if(Array.isArray(val)) return this.err(`is an array`, val); + if(val === null) return this.err(`is null`, val); for(const prop in val) { const result = this.valueType.check(val[prop]); - if(result instanceof Err) return new Err(`[${prop}]: ${result.message}`); + if(result instanceof Err) return Err.lift(result, prop) } return val as Result>; } + + toString() { + return `{ [key: string]: ${this.valueType} }` + } } export function dict(v: Type): Dict { diff --git a/lib/checks/instance-of.ts b/lib/checks/instance-of.ts index 2363a51..fb3df51 100644 --- a/lib/checks/instance-of.ts +++ b/lib/checks/instance-of.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Result } from "../result"; import { Type } from "../type"; type Constructor = Function & { prototype: T } @@ -13,7 +13,14 @@ export class InstanceOf extends Type { check(val: any): Result { if(val instanceof this.klass) return val; - return new Err(`${val} is not an instance of ${this.klass}`); + return this.err(() => `not an instance of ${this.klass}`, val); + } + + toString() { + if (this.klass.name) { + return this.klass.name + } + return `instanceof ${this.klass}` } } diff --git a/lib/checks/is.ts b/lib/checks/is.ts index 8d1fd04..c5ea8e1 100644 --- a/lib/checks/is.ts +++ b/lib/checks/is.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Result } from "../result"; import { Type } from "../type"; type Guard = (val: any) => val is T @@ -15,7 +15,11 @@ export class Is extends Type { check(val: any): Result { if(this.isT(val)) return val; - return new Err(`${val} is not a ${this.name} (guard failed)`) + return this.err(`guard failed`, val) + } + + toString() { + return `is(${this.name}, ${this.isT})` } } diff --git a/lib/checks/map.ts b/lib/checks/map.ts index 0c83b90..a3c8c48 100644 --- a/lib/checks/map.ts +++ b/lib/checks/map.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Err, MapKey, MapKeyIndex, Result } from "../result"; import { Type } from "../type"; export class MapType extends Type> { @@ -12,17 +12,23 @@ export class MapType extends Type> { } check(val: any): Result> { - if(!(val instanceof Map)) return new Err(`${val} is not an instance of Map`); + if(!(val instanceof Map)) return this.err(`not a Map`, val); + let i = 0 for(const [k, v] of val) { const kResult = this.keyType.check(k); - if(kResult instanceof Err) return new Err(`{val} key error: ${kResult.message}`); + if(kResult instanceof Err) return Err.lift(kResult, new MapKeyIndex(i)) const vResult = this.valueType.check(v); - if(vResult instanceof Err) return new Err(`{val} value error: ${vResult.message}`); + if(vResult instanceof Err) return Err.lift(vResult, new MapKey(k)) + i++ } return val as Map; } + + toString() { + return `Map<${this.keyType}, ${this.valueType}>` + } } export function map(k: Type, v: Type): MapType { diff --git a/lib/checks/never.ts b/lib/checks/never.ts index 5b8c888..37fa8f2 100644 --- a/lib/checks/never.ts +++ b/lib/checks/never.ts @@ -1,10 +1,2 @@ -import { Result, Err } from "../result"; -import { Type } from "../type"; - -export class Never extends Type { - check(_: any): Result { - return new Err('never') - } -} - -export const never = new Never(); +import { never, Never } from "../type"; +export { never, Never } diff --git a/lib/checks/set.ts b/lib/checks/set.ts index f35b0c6..04117a3 100644 --- a/lib/checks/set.ts +++ b/lib/checks/set.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Err, Result, MapKeyIndex } from "../result"; import { Type } from "../type"; export class SetType extends Type> { @@ -10,13 +10,19 @@ export class SetType extends Type> { } check(val: any): Result> { - if(!(val instanceof Set)) return new Err(`${val} is not an instance of Set`); + if(!(val instanceof Set)) return this.err(`not an instance of Set`, val); + let i = 0 for(const v of val) { const result = this.valueType.check(v); - if(result instanceof Err) return new Err(`{val} failed set check on value ${v}: ${result.message}`); + if(result instanceof Err) return Err.lift(result, new MapKeyIndex(i)) + i++ } return val as Set; } + + toString() { + return `Set<${this.valueType}>` + } } export function set(v: Type): SetType { diff --git a/lib/checks/struct.ts b/lib/checks/struct.ts index b90fca1..1e786b3 100644 --- a/lib/checks/struct.ts +++ b/lib/checks/struct.ts @@ -1,4 +1,4 @@ -import { Err } from "../result"; +import { Err, shouldWrap, indentNext } from "../result"; import { KeyTrackResult, Type, KeyTrackingType } from "../type"; import { GetType } from "../get-type"; @@ -72,18 +72,25 @@ export class Struct extends KeyTrackingType> | undefined { - if(typeof val !== 'object') return new Err(`${val} is not an object`); - if(Array.isArray(val)) return new Err(`${val} is an array`); - if(val === null) return new Err(`${val} is null`); + if(typeof val !== 'object') return this.err(`isn't an object`, val) + if(Array.isArray(val)) return this.err('is an array', val) + if(val === null) return this.err('is null', val) return undefined; } - private checkFields(val: any): string[] { - const errs: string[] = []; + private checkFields(val: any): Err[] { + const errs: Err[] = []; for(const prop in this.definition) { const field = this.definition[prop] if (!(prop in val)) { @@ -91,16 +98,33 @@ export class Struct extends KeyTrackingType { + const value = this.definition[key] + const keystr = isOptional(value) ? `${key}?: ` : `${key}: ` + // is this cooL??? hard to reason about + const valstr = keyType(value).toString() + kvs.push(keystr + valstr) + }) + const sep = shouldWrap(kvs) ? ",\n" : ', ' + return '{ ' + indentNext(kvs.join(sep)) + ' }' + } } type HiddenStruct = Type>; diff --git a/lib/checks/type-of.ts b/lib/checks/type-of.ts index de39f2e..53a4696 100644 --- a/lib/checks/type-of.ts +++ b/lib/checks/type-of.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Result } from "../result"; import { Type } from "../type"; export class TypeOf extends Type { @@ -10,7 +10,11 @@ export class TypeOf extends Type { check(val: any): Result { if(typeof val === this.typestring) return val as T; - return new Err(`${val} is not a ${this.typestring}`); + return this.err(`not a ${this.typestring}`, val) + } + + toString() { + return this.typestring } } diff --git a/lib/checks/value.ts b/lib/checks/value.ts index 03c1bd6..1141891 100644 --- a/lib/checks/value.ts +++ b/lib/checks/value.ts @@ -1,4 +1,4 @@ -import { Err, Result } from "../result"; +import { Result } from "../result"; import { Type } from "../type"; export class Value extends Type { @@ -10,7 +10,17 @@ export class Value extends Type { check(val: any): Result { if(val === this.val) return val; - return new Err(`${val} is not equal to ${this.val}`); + return this.err('not equal', val) + } + + toString() { + if (typeof this.val === 'string' || + typeof this.val === 'number' || + this.val === null + ) { + return JSON.stringify(this.val) + } + return `=== ${this.val}` } } diff --git a/lib/result.ts b/lib/result.ts index c3b1463..19cb7f3 100644 --- a/lib/result.ts +++ b/lib/result.ts @@ -1,11 +1,272 @@ +import { Type } from './type' +import { inspect } from 'util' + +const MAX_ERR_LINE_LENGTH = 40 +const INDENT = ' ' + +export const indent = (lines: string | string[], prefix = INDENT) => + (Array.isArray(lines) ? lines : lines.split("\n")) + .map(line => prefix + line) + .join("\n") + +export const indentNext = (lines: string | string[], prefix = INDENT) => { + const [first, ...rest] = Array.isArray(lines) ? lines : lines.split("\n") + if (rest.length === 0) { return first } + return [first, ...indent(rest, prefix).split("\n")].join("\n") +} + +export function shouldWrap(msg: string | string[]): boolean { + const parts = Array.isArray(msg) ? msg : [msg]; + + const len = parts.map(s => s.length).reduce((m, x) => m + x, 0) + if (len > MAX_ERR_LINE_LENGTH) { return true } + + const hasNewline = parts.map(s => s.includes("\n")).reduce((m, x) => m || x) + return hasNewline +} + +function quoteOrIndent(msg: string, suffix?: (inline: boolean) => string): string { + if (shouldWrap(msg)) { + return ("\n" + + indent(msg) + + (msg.endsWith("\n") ? '' : "\n") + + (suffix ? suffix(false) : '')) + } + return '`' + msg + '`' + (suffix ? suffix(true) : '') +} + +function subMessage(pre: string, msg: string): string { + if (pre.includes("\n")) { + return concat(pre, indentNext(msg)) + } + return concat(pre, "\n" + indent(msg)) +} + +function concat(...msgs: string[]): string { + const out = [] + for (let i = 0; i string + +type ErrOpts<_> = { + value: any, + type: Type, + path?: PathElement[], + causes?: Err<_>[] +} + +const errSep = (inline: boolean) => inline ? ':' : 'because:' + export class Err<_> { - message: string; - constructor(str: string) { - this.message = str; + path: PathElement[] // array defining access to the field in `data`. Will be [] if element is data + value: any // The invalid value + type: Type // Expected type of value + causes: Err<_>[] + protected msg: string | tostr; + + static lift<_>(err: Err<_>, ...path: PathElement[]) { + return new Err<_>( + err.msg, + { + value: err.value, + type: err.type, + path: path.concat(err.path), + causes: err.causes, + } + ) + } + + // TODO: disallow causes option + static combine<_>(errs: Err[], opts: ErrOpts<_>): Err<_> { + return new Err( + () => { + if (errs.length === 1) { return errs[0].toString() } + const errStrings = errs.map((e) => `- ${e}`).join("\n") + return `failed multiple checks:\n${errStrings}` + }, + { ...opts, causes: errs }, + ) + } + + constructor(msg: string | tostr, {path = [], value, type, causes = []}: ErrOpts<_>) { + this.msg = msg + this.path = path + this.value = value + this.type = type + this.causes = causes + } + + get message(): string { + const msg = typeof this.msg === 'function' ? + this.msg() : this.msg + this.msg = msg + return msg + } + + protected rootCauses(parentPath: PathElement[]): Err<_>[] { + if (this.causes.length === 0) { + return [Err.lift(this, ...parentPath)] + } + return this.causes + .map(c => c.rootCauses(parentPath.concat(this.path))) + .reduce((x, m) => x.concat(m), []) + } + + toError(): StructuralError { + // force expanding all errors now so that the lifted errors in rootCauses + // below are already memoized + const asString = this.toStringWithValue() + + // flatten causes, which is more inspectable to downstream exception + // handlers + const causes = this.causes + .map(c => c.rootCauses(this.path)) + .reduce((x, m) => x.concat(m), []) + + return new StructuralError( + asString, + { + value: this.value, + type: this.type, + causes: causes, + } + ); + } + + toStringWithValue() { + const pathPart = this.path.length ? ['at', `${pathToString(this.path)}:`] : [] + return concat( + ...pathPart, + 'given value', quoteOrIndent(inspect(this.value)), + 'did not match expected type', subMessage( + quoteOrIndent(this.type.toString(), errSep), + this.message, + ) + ) + } + + toStringNoValue() { + return concat( + 'not of type', + subMessage( + quoteOrIndent(this.type.toString(), errSep), + this.message, + ), + ) + } + + // Use this stringifier when embedding an error inside another error's message. + // Avoids printing the value again for errors that don’t modify the path + toString() { + if (this.path.length === 0) { return this.toStringNoValue() } + return this.toStringWithValue() + } +} + +/** + * KeyIndex represents a specific key in a map when the key itself is invalid. + * Consider a map of Map. If we assert for a concrete value that is actually + * a Map, we can use KeyIndex to indicate that it is the Nth key + * which is invalid (because it is a number). This is even more useful if the key + * is a struct, and some deep field of the struct has gone wrong. + */ +export class MapKeyIndex { + readonly index: number + constructor(n: number) { + this.index = n + } + + toString() { + return `.keys()[${this.index}]` + } + + apply(current: any) { + const keys = Array.from(current.keys()) + if (keys.length <= this.index) { + throw new Error(`${this} index out of bounds of keys ${keys.length} in ${current}`) + } + return keys[this.index] } +} + +/** + * For map types, this boxes the key so it's clear we're accessing a map. If we just used `any` + * in the path, we couldn't be sure when pulling out KeyIndex values what those values meant. + */ +export class MapKey { + readonly val: any + constructor(v: any) { + this.val = v + } + + toString() { + return `.get(${this.val.toString()})` + } + + apply(current: any) { + return current.get(this.val) + } +} +type PathElement = string | number | MapKeyIndex | MapKey + +// is there any point to this other than intellectual exercise?? +export function lookupPath(val: any, path: PathElement[]): any { + if (path.length === 0) { + return val + } + + let current = val + path.forEach((key) => { + if (key instanceof MapKeyIndex) { + current = key.apply(current) + return + } + if (key instanceof MapKey) { + current = key.apply(current) + return + } + current = current[key] + }) + return current +} + +export function pathToString(lookupPath: PathElement[]): string { + let out: string[] = [] + for (let i = 0; i // Expected type of value + readonly causes: Err[] // Flattened causes of this error - toError() { - return new Error(this.message); + constructor(msg: string, opts: Pick) { + super(msg) + this.value = opts.value + this.type = opts.type + this.causes = opts.causes } } diff --git a/lib/type.ts b/lib/type.ts index 442a7fb..5be1359 100644 --- a/lib/type.ts +++ b/lib/type.ts @@ -1,14 +1,14 @@ -import { Err, Result } from "./result"; +import { Err, Result, shouldWrap, indent, indentNext } from "./result"; export abstract class Type { abstract check(val: any): Result; assert(val: any): T { - return assert(this.check(val)); + return assert(this.check(val), this, val); } slice(val: any): T { - return assert(this.sliceResult(val)); + return assert(this.sliceResult(val), this, val); } /** @@ -37,6 +37,8 @@ export abstract class Type { return val; } + abstract toString(): string + /* * Default slice implementation just calls `check`. Override this as necessary. */ @@ -65,10 +67,24 @@ export abstract class Type { validate(desc: string, fn: Validator): Type { return this.and(new Validation(desc, fn)); } + + protected err<_>(msg: string | tostr, value: any): Err<_> { + return new Err<_>(msg, { + value, + type: this, + }) + } } +type tostr = () => string -function assert(result: Result): T { - if(result instanceof Err) throw result.toError(); +function assert(result: Result, type: Type, value: any): T { + if(result instanceof Err) { + if (result.path.length) { + const final = Err.combine([result], { type, value }) + throw final.toError(); + } + throw result.toError(); + } return result; } @@ -106,10 +122,14 @@ export class Validation extends Type { try { if(this.validator(val)) return val; } catch(e) { - return new Err(`Validation \`${this.desc}\` threw an error: ${e}`); + return this.err(`validation error: ${e}`, val); } - return new Err(`Failed validation: ${this.desc}`); + return this.err(`validation returned false`, val); + } + + toString() { + return `validate(${this.desc}, ${this.validator})` } } @@ -159,7 +179,7 @@ export abstract class KeyTrackingType extends Type { const result = this.checkTrackKeys(val); if(result instanceof Err) return result; - return exactError(val, result) || result.val; + return exactError(val, result, this) || result.val; } /* @@ -171,7 +191,7 @@ export abstract class KeyTrackingType extends Type { const result = this.checkTrackKeys(val); if(result instanceof Err) return result; - const err = exactError(val, result); + const err = exactError(val, result, this); if(err) return err; if(result.knownKeys == null) return result.val; @@ -215,7 +235,59 @@ export class Either extends KeyTrackingType { if(!(l instanceof Err)) return l; const r = checkTrackKeys(this.r, val); if(!(r instanceof Err)) return r; - return new Err(`${val} failed the following checks:\n${l.message}\n${r.message}`); + + let causes: Err[] = [] + if (this.l instanceof Either) { + causes = causes.concat(l.causes) + } else { + causes.push(this.rebase(l, this.l, val)) + } + if (this.r instanceof Either) { + causes = causes.concat(r.causes) + } else { + causes.push(this.rebase(r, this.r, val)) + } + + const msg = () => { + let lines = [`matched none of ${causes.length} types:`] + causes.forEach(err => { + lines.push(`| ${indentNext(err.type.toString())}`) + lines.push(indent(err.message)) + }) + return lines.join("\n") + } + + const err = this.err(msg, val) + err.causes = causes + return err + } + + toString(): string { + const l = this.l instanceof Intersect ? + `(${this.l})` : this.l.toString() + const r = this.r instanceof Intersect ? + `(${this.r})` : this.r.toString() + if (shouldWrap([l, r])) { + return `${l} |\n${r}` + } + return `${l} | ${r}` + } + + // "rebase" an error to have the given type. If the error does not have that + // type currently, produce a new one with the original error type displayed + // in the string and as a cause. + // + // The messages here will never be converted to a StructuralError. Instead they're + // going to be nested into the error returned from this Either. + private rebase<_>(err: Err<_>, type: Type, value: any): Err<_> { + if (err.type === type && err.path.length === 0) { + return err + } + return new Err<_>(() => err.toString(), { + type, + value, + causes: [err], + }) } } @@ -243,7 +315,20 @@ export class Intersect extends KeyTrackingType { const r = checkTrackKeys(this.r, val); if((l instanceof Err) && (r instanceof Err)) { - return new Err(`${val} failed the following checks:\n${l.message}\n${r.message}`); + let causes: Err[] = [] + if (this.left instanceof Intersect) { + causes = causes.concat(l.causes) + } else { + causes.push(l) + } + if (this.r instanceof Intersect) { + causes = causes.concat(r.causes) + } else { + causes.push(r) + } + const err = this.err(() => `failed checks:\n${causes.join("\n")}`, val) + err.causes = causes + return err } if(l instanceof Err) return l; if(r instanceof Err) return r; @@ -259,6 +344,14 @@ export class Intersect extends KeyTrackingType { exact: l.exact && r.exact, } } + + toString() { + const l = this.left instanceof Either ? + `(${this.left})` : this.left + const r = this.r instanceof Either ? + `(${this.r})` : this.r + return `${l} & ${r}` + } } /* @@ -284,21 +377,50 @@ function checkTrackKeys(check: Type, val: any): KeyTrackResult { }; } +export class Never extends Type { + check(val: any): Result { + return this.err('never values cannot occur', val) + } + + toString() { + return 'never' + } +} + +export const never = new Never(); + /* * Given a value and a KeyTrack result, either return a nice error message if it fails exactness * checking, or return undefined if there is no error. */ -export function exactError(val: any, result: KeyTrack): Err | undefined { - if(!result.exact) return; +export function exactError(val: any, result: KeyTrack, t: Type): Err | undefined { + if(result.exact) { + const errs: Err[] = []; + const allowed = new Set(result.knownKeys); + for(const prop in val) { + if(!allowed.has(prop)) { + errs.push( + new Err(() => `unknown key \`${prop}\` should not exist`, { + value: val[prop], + // this lie so we don't have a circular import + // from checks. + type: never, + path: [prop] + }) + ) + } + } - const errs = []; - const allowed = new Set(result.knownKeys); + if (errs.length === 1) { + return errs[0] + } - for(const prop in val) { - if(!allowed.has(prop)) errs.push(`Unknown key ${prop} in ${val}`); + if(errs.length > 1) { + return Err.combine(errs, { + value: val, + type: t, + }) + } } - - if(errs.length !== 0) return new Err(`${val} failed the following checks:\n${errs.join('\n')}`); - - return; + return undefined } diff --git a/package.json b/package.json index 1e82fb7..7c3f2d5 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,8 @@ "/build", "/lib", "/test" - ] + ], + "dependencies": { + "@types/node": "^10.12.18" + } } diff --git a/test/__snapshots__/errors.ts.snap b/test/__snapshots__/errors.ts.snap new file mode 100644 index 0000000..aa4459c --- /dev/null +++ b/test/__snapshots__/errors.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`error integration tests either enumerates the missed types 1`] = ` +"given value + { owner: + { username: 'the duke', age: 42, email: 'duke.nuke@example.com' }, + contents: + [ { text: 'Those who do not study history are doomed to repeat it', + author: 'Albert Einstien', + date: 1970-01-01T00:00:02.001Z }, + { uri: 'example.com', + alt: 'Mr. Doctor Albert Einstien wearing a tweed jacket' } ] } +did not match expected type + { owner: { username: string, + age?: number, + email: string & validate(has @ symbol, (val) => !!val.match(/@/)) }, + contents: Array<{ text: string, author: string, date?: Date } | + { uri: string & validate(starts with http, (val) => !!val.match(/^http/)), + alt: string }> } +because: at .contents[1]: given value + { uri: 'example.com', + alt: 'Mr. Doctor Albert Einstien wearing a tweed jacket' } + did not match expected type + { text: string, author: string, date?: Date } | + { uri: string & validate(starts with http, (val) => !!val.match(/^http/)), + alt: string } + because: matched none of 2 types: + | { text: string, author: string, date?: Date } + failed multiple checks: + - at .text: given value \`undefined\` did not match expected type \`string\`: + key missing + - at .author: given value \`undefined\` did not match expected type \`string\`: + key missing + | { uri: string & validate(starts with http, (val) => !!val.match(/^http/)), + alt: string } + at .uri: given value \`'example.com'\` did not match expected type + validate(starts with http, (val) => !!val.match(/^http/)) + because: validation returned false" +`; + +exports[`error integration tests exact prints fields nice 1`] = ` +"given value + { text: 'Those who do not study history are doomed to repeat it', + author: 'Albert Einstien', + date: 1970-01-01T00:00:02.001Z, + badBoy: true } +did not match expected type + { text: string, author: string, date?: Date } +because: at .badBoy: given value \`true\` did not match expected type \`never\`: + unknown key \`badBoy\` should not exist" +`; diff --git a/test/algebra.ts b/test/algebra.ts index 5ce9bb1..e677510 100644 --- a/test/algebra.ts +++ b/test/algebra.ts @@ -247,4 +247,11 @@ describe("and", () => { check.assert({}); }).toThrow(); }); + + test("toString", () => { + const check = t.num.or(t.str) + expect(check.toString()).toEqual("number | string") + + expect(t.num.and(t.value(5)).toString()).toEqual("number & 5") + }) }); diff --git a/test/any.ts b/test/any.ts index c700779..2ff58be 100644 --- a/test/any.ts +++ b/test/any.ts @@ -5,3 +5,7 @@ test("accepts anything", () => { t.any.assert("five"); t.any.assert({}); }); + +test("toString", () => { + expect(t.any.toString()).toEqual("any") +}) diff --git a/test/array.ts b/test/array.ts index 0849d50..bccb58a 100644 --- a/test/array.ts +++ b/test/array.ts @@ -23,3 +23,7 @@ test("rejects non-arrays", () => { check.assert(null); }).toThrow(); }); + +test("toString", () => { + expect(t.array(t.str).toString()).toEqual("Array") +}) diff --git a/test/dict.ts b/test/dict.ts index 86585b5..4d3a7c8 100644 --- a/test/dict.ts +++ b/test/dict.ts @@ -42,3 +42,7 @@ test("rejects arrays", () => { check.assert([ ]); }).toThrow(); }); + +test("toString", () => { + expect(t.dict(t.any).toString()).toEqual("{ [key: string]: any }") +}) diff --git a/test/errors.ts b/test/errors.ts new file mode 100644 index 0000000..cbcb059 --- /dev/null +++ b/test/errors.ts @@ -0,0 +1,115 @@ +/* noStripWhitespace - disable vim stripping trailing whitespace on file write. +* This is important for the expected error message strings below */ +import * as t from '..' +import { indent, indentNext } from '../lib/result' + +describe("error integration tests", () => { + const User = t.subtype({ + username: t.str, + age: t.optional(t.num), + email: t.str.validate('has @ symbol', (val) => !!val.match(/@/)) + }) + + const Quote = t.exact({ + text: t.str, + author: t.str, + date: t.optional(t.instanceOf(Date)), + }) + + const Pic = t.subtype({ + uri: t.str.validate('starts with http', (val) => !!val.match(/^http/)), + alt: t.str, + }) + + const Post = t.subtype({ + owner: User, + contents: t.array(Quote.or(Pic)), + }) + + function value(): t.GetType { + return { + owner: { + username: 'the duke', + age: 42, + email: 'duke.nuke@example.com', + }, + contents: [ + { + text: 'Those who do not study history are doomed to repeat it', + author: 'Albert Einstien', + date: new Date(2001), + }, + { + uri: 'https://example.com/pics/albert.jpg', + alt: 'Mr. Doctor Albert Einstien wearing a tweed jacket', + } + ] + } + } + + function captureErr(block: () => void): Error { + let err: Error + try { + block() + } catch (e) { + err = e + return err + } + throw "no error produced" + } + + test('the default value is valid', () => { + Post.assert(value()) + }) + + test("either enumerates the missed types", () => { + const val = value(); + (val.contents[1] as any).uri = 'example.com' + + expect(() => { + Post.assert(val) + }).toThrowErrorMatchingSnapshot() + }) + + test("exact prints fields nice", () => { + const val = value().contents[0] + ;(val as any).badBoy = true + + expect(() => { + Quote.assert(val) + }).toThrowErrorMatchingSnapshot() + }) + + test("error contains a full .causes", () => { + const err = captureErr(() => { + const val = value(); + (val.contents[1] as any).uri = 'example.com' + Post.assert(val) + }) + + expect(err).toBeInstanceOf(t.StructuralError) + const se = err as t.StructuralError + expect(se.causes).toHaveLength(3) + expect(se.causes[0].toString()).toMatch(/at .contents\[1].text/) + expect(se.causes[1].toString()).toMatch(/at .contents\[1].author/) + expect(se.causes[2].toString()).toMatch(/at .contents\[1].uri/) + }) + + describe("helpers", () => { + describe("indentNext", () => { + test("does not make any changes to single-line strings", () => { + expect(indentNext("foo")).toEqual("foo") + }) + + test("indents lines after the first line", () => { + expect(indentNext("foo\nbar\nbaz")).toEqual("foo\n bar\n baz") + }) + }) + + describe("indent", () => { + test("indents all lines", () => { + expect(indent("foo\nbar")).toEqual(" foo\n bar") + }) + }) + }) +}) diff --git a/test/instance-of.ts b/test/instance-of.ts index 35e922e..24078cf 100644 --- a/test/instance-of.ts +++ b/test/instance-of.ts @@ -2,6 +2,15 @@ import * as t from ".."; class A {} class B {} +const Wat: any = function(this: any, name: string): any { +this.name = name +} +delete Wat.name +Wat.prototype = { + greet: function() { + return "hello " + this.name + } +} test("accepts values that are an instance of the class", () => { const check = t.instanceOf(A); @@ -14,3 +23,10 @@ test("rejects values that are not an instance of the class", () => { check.assert(new B()); }).toThrow(); }); + +test("toString", () => { + expect(t.instanceOf(A).toString()).toEqual("A") + expect(t.instanceOf(Wat).toString()).toEqual(`instanceof function (name) { + this.name = name; +}`) +}) diff --git a/test/is.ts b/test/is.ts index c24ebf1..4386387 100644 --- a/test/is.ts +++ b/test/is.ts @@ -31,3 +31,11 @@ test("works as a guard w/ type inference", () => { expect(success).toBe(true) }) + +test("toString", () => { + class Doggo { + bark() { return true } + } + const check = t.is("a doggo", (val: any): val is Doggo => val instanceof Doggo) + expect(check.toString()).toEqual("is(a doggo, (val) => val instanceof Doggo)") +}) diff --git a/test/map.ts b/test/map.ts index 7f31df0..71448c5 100644 --- a/test/map.ts +++ b/test/map.ts @@ -36,3 +36,8 @@ test("rejects non-maps", () => { check.assert(null); }).toThrow(); }); + +test("toString", () => { + const check = t.map(t.obj, t.num) + expect(check.toString()).toEqual(`Map`) +}) diff --git a/test/never.ts b/test/never.ts index 1934e95..6c936cd 100644 --- a/test/never.ts +++ b/test/never.ts @@ -12,3 +12,7 @@ test("accepts nothing", () => { t.never.assert({}); }).toThrow(); }); + +test("toString", () => { + expect(t.never.toString()).toEqual("never") +}) diff --git a/test/set.ts b/test/set.ts index 09f9576..4b6931b 100644 --- a/test/set.ts +++ b/test/set.ts @@ -28,3 +28,7 @@ test("rejects non-sets", () => { check.assert(null); }).toThrow(); }); + +test("toString", () => { + expect(t.set(t.any).toString()).toEqual("Set") +}) diff --git a/test/struct.ts b/test/struct.ts index 1d29b64..7be5c98 100644 --- a/test/struct.ts +++ b/test/struct.ts @@ -195,3 +195,12 @@ describe('slice', () => { }).toThrow(); }); }); + +test("toString", () => { + const check = t.subtype({ + name: t.str, + age: t.optional(t.num) + }) + + expect(check.toString()).toEqual(`{ name: string, age?: number }`) +}) diff --git a/test/validate.ts b/test/validate.ts index 0d90a44..04ebb20 100644 --- a/test/validate.ts +++ b/test/validate.ts @@ -22,3 +22,8 @@ test("fails when the fn throws", () => { expect(check.check(0)).toBeInstanceOf(t.Err); }); + +test("toString", () => { + const check = t.num.validate("big enuf", (val) => val > 5) + expect(check.toString()).toEqual("number & validate(big enuf, (val) => val > 5)") +}) diff --git a/test/value.ts b/test/value.ts index 894c94e..788e4be 100644 --- a/test/value.ts +++ b/test/value.ts @@ -11,3 +11,11 @@ test("rejects non-matching values", () => { check.assert(6); }).toThrow(); }); + +test("toString", () => { + const check = t.value("foo").or(t.value("bar")) + expect(check.toString()).toEqual(`"foo" | "bar"`) + + const check2 = t.value(new Map([[{}, 1]])) + expect(check2.toString()).toEqual("=== [object Map]") +}) diff --git a/yarn.lock b/yarn.lock index 027067a..f9addf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,6 +23,11 @@ resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.10.tgz#4897974cc317bf99d4fe6af1efa15957fa9c94de" integrity sha512-DC8xTuW/6TYgvEg3HEXS7cu9OijFqprVDXXiOcdOKZCU/5PJNLZU37VVvmZHdtMiGOa8wAA/We+JzbdxFzQTRQ== +"@types/node@^10.12.18": + version "10.12.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.18.tgz#1d3ca764718915584fcd9f6344621b7672665c67" + integrity sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ== + abab@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f"