diff --git a/src/index.ts b/src/index.ts index 0af62149d..99c06b7a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -861,8 +861,7 @@ export const type =

(props: P, name: string = getInterfaceTypeN if (UnknownRecord.is(u)) { for (let i = 0; i < len; i++) { const k = keys[i] - const uk = u[k] - if ((uk === undefined && !hasOwnProperty.call(u, k)) || !types[i].is(uk)) { + if (!(k in u) || !types[i].is(u[k])) { return false } } @@ -878,12 +877,15 @@ export const type =

(props: P, name: string = getInterfaceTypeN const k = keys[i] const ak = a[k] const type = types[i] - const result = type.validate(ak, appendContext(c, k, type, ak)) + const result = + ak === undefined && !(k in a) + ? failure(ak, appendContext(c, k, type, ak)) + : type.validate(ak, appendContext(c, k, type, ak)) if (isLeft(result)) { pushAll(errors, result.left) } else { const vak = result.right - if (vak !== ak || (vak === undefined && !hasOwnProperty.call(a, k))) { + if (vak !== ak || (vak === undefined && !(k in a))) { /* istanbul ignore next */ if (a === o) { a = { ...o } diff --git a/test/exact.ts b/test/exact.ts index 9d75ae81d..94299c3ce 100644 --- a/test/exact.ts +++ b/test/exact.ts @@ -64,7 +64,7 @@ describe('exact', () => { it('should succeed validating an undefined field', () => { const T = t.exact(t.type({ foo: t.string, bar: t.union([t.string, t.undefined]) })) - assertSuccess(T.decode({ foo: 'foo' })) + assertSuccess(T.decode({ foo: 'foo', bar: undefined })) }) it('should return the same reference if validation succeeded', () => { diff --git a/test/recursion.ts b/test/recursion.ts index 537c474c4..2dc3e5944 100644 --- a/test/recursion.ts +++ b/test/recursion.ts @@ -71,11 +71,19 @@ describe('recursion', () => { assertStrictEqual(T.decode(value), value) }) - it('should fail validating an invalid value', () => { + it('should fail validating { a: number, b: ( self | undefined | null) } for value 1', () => { assertFailure(T, 1, ['Invalid value 1 supplied to : T']) - assertFailure(T, {}, ['Invalid value undefined supplied to : T/a: number']) + }) + it('should fail validating { a: number, b: ( self | undefined | null) } for value {}', () => { + assertFailure(T, {}, [ + 'Invalid value undefined supplied to : T/a: number', + 'Invalid value undefined supplied to : T/b: (T | undefined | null)' + ]) + }) + it('should fail validating { a: number, b: ( self | undefined | null) } for value { a: 1, b: {} }', () => { assertFailure(T, { a: 1, b: {} }, [ 'Invalid value undefined supplied to : T/b: (T | undefined | null)/0: T/a: number', + 'Invalid value undefined supplied to : T/b: (T | undefined | null)/0: T/b: (T | undefined | null)', 'Invalid value {} supplied to : T/b: (T | undefined | null)/1: undefined', 'Invalid value {} supplied to : T/b: (T | undefined | null)/2: null' ]) diff --git a/test/strict.ts b/test/strict.ts index 35a4cf7d4..c0357b3e8 100644 --- a/test/strict.ts +++ b/test/strict.ts @@ -30,7 +30,7 @@ describe('strict', () => { assert.strictEqual(T.is(undefined), false) }) - it('#423', () => { + it('should allow properties to be satisified by getters - #423', () => { class A { get a() { return 'a' @@ -42,6 +42,26 @@ describe('strict', () => { const T = t.strict({ a: t.string, b: t.string }) assert.strictEqual(T.is(new A()), true) }) + + it('should return false for a missing undefined property ', () => { + const T = t.strict({ a: t.string, b: t.undefined }) + assert.strictEqual(T.is({ a: 'a' }), false) + }) + + it('should return false for a missing unknown property ', () => { + const T = t.strict({ a: t.string, b: t.unknown }) + assert.strictEqual(T.is({ a: 'a' }), false) + }) + + it('should return true for a missing undefined property that is optional', () => { + const T = t.intersection([t.type({ a: t.string }), t.partial({ b: t.undefined })]) + assert.strictEqual(T.is({ a: 'a' }), true) + }) + + it('should return true for a missing unknown property that is optional', () => { + const T = t.intersection([t.type({ a: t.string }), t.partial({ b: t.unknown })]) + assert.strictEqual(T.is({ a: 'a' }), true) + }) }) describe('decode', () => { @@ -52,7 +72,7 @@ describe('strict', () => { it('should succeed validating an undefined field', () => { const T = t.strict({ foo: t.string, bar: t.union([t.string, t.undefined]) }) - assertSuccess(T.decode({ foo: 'foo' })) + assertSuccess(T.decode({ foo: 'foo', bar: undefined })) }) it('should return the same reference if validation succeeded', () => { @@ -66,6 +86,26 @@ describe('strict', () => { assertFailure(T, { foo: 1 }, ['Invalid value 1 supplied to : {| foo: string |}/foo: string']) }) + it('should fail validating a missing undefined value', () => { + const T = t.strict({ a: t.string, b: t.undefined }) + assertFailure(T, { a: 'a' }, ['Invalid value undefined supplied to : {| a: string, b: undefined |}/b: undefined']) + }) + + it('should fail validating a missing unknown value', () => { + const T = t.strict({ a: t.string, b: t.unknown }) + assertFailure(T, { a: 'a' }, ['Invalid value undefined supplied to : {| a: string, b: unknown |}/b: unknown']) + }) + + it('should succeed validating a missing undefined value that is optional', () => { + const T = t.intersection([t.type({ a: t.string }), t.partial({ b: t.unknown })]) + assertSuccess(T.decode({ a: 'a' })) + }) + + it('should succeed validating a missing unknown value that is optional', () => { + const T = t.intersection([t.type({ a: t.string }), t.partial({ b: t.unknown })]) + assertSuccess(T.decode({ a: 'a' })) + }) + it('should strip additional properties', () => { const T = t.strict({ foo: t.string }) assertSuccess(T.decode({ foo: 'foo', bar: 1, baz: true }), { foo: 'foo' }) diff --git a/test/type.ts b/test/type.ts index 813dbb8a6..d6c7fee04 100644 --- a/test/type.ts +++ b/test/type.ts @@ -17,20 +17,328 @@ describe('type', () => { }) }) - describe('is', () => { - it('should return `true` on valid inputs', () => { - const T = t.type({ a: t.string }) - assert.strictEqual(T.is({ a: 'a' }), true) + const successCases = [ + // [ props, name, value ] + [{ a: t.null }, undefined, { a: null }], + [{ a: t.nullType }, undefined, { a: null }], + [{ a: t.undefined }, undefined, { a: undefined }], + [{ a: t.void }, undefined, { a: undefined }], + [{ a: t.voidType }, undefined, { a: undefined }], + [{ a: t.unknown }, undefined, { a: 'a' }], + [{ a: t.unknown }, undefined, { a: undefined }], + [{ a: t.string }, undefined, { a: 'a' }], + [{ a: t.number }, undefined, { a: Number.MAX_VALUE }], + [{ a: t.bigint }, undefined, { a: BigInt(Number.MAX_VALUE + 1), toJSON: () => `a: BigInt(Number.MAX_VALUE +1)` }], + [{ a: t.boolean }, undefined, { a: true }], + [{ a: t.UnknownArray }, undefined, { a: [[1], [2]] }], + [{ a: t.UnknownArray }, undefined, { a: [] }], + [{ a: t.UnknownArray }, undefined, { a: [undefined] }], + [{ a: t.UnknownArray }, undefined, { a: [[undefined], [2]] }], + [{ a: t.array(t.number) }, 'Array', { a: [1, 2] }], + [{ a: t.UnknownRecord }, undefined, { a: { b: 'b' } }], + [{ a: t.UnknownRecord }, undefined, { a: {} }], + [{ a: t.UnknownRecord }, undefined, { a: { undefined } }], + [{ a: t.UnknownRecord }, undefined, { a: { b: undefined } }], + [{ a: t.record(t.string, t.string) }, '{ a: { [K in string]: string } }', { a: { b: 'b' } }], + [{ a: t.Int }, '{ a: Int }', { a: -1 }], + [{ a: t.literal('YES') }, '{ a: "YES" }', { a: 'YES' }], + [{ a: t.partial({ b: t.string, c: t.string }) }, '{ a: Partial<{ b: string, c:string }> }', { a: {} }], + [ + { a: t.partial({ b: t.string, c: t.string }) }, + '{ a: Partial<{ b: string, c: string }> }', + { a: { b: 'b', c: undefined } } + ], + [{ a: t.readonly(t.number) }, '{ a: Readonly }', { a: 1 }], + [{ a: t.readonlyArray(t.number) }, '{ a: ReadonlyArray }', { a: [1, 2] }], + [{ a: t.type({ b: t.string, c: t.string }) }, '{ a: { b: string, c: string }', { a: { b: 'b', c: 'c' } }], + [{ a: t.tuple([t.string, t.string]) }, '{ a: [string, string] }', { a: ['A', 'B'] }], + [ + { a: t.union([t.string, t.number]), b: t.union([t.string, t.number]) }, + '{ a: (string | number), b: (string | number) }', + { a: 1, b: 'b' } + ], + [{ a: t.intersection([t.number, t.Int]) }, '{ a: (number & Int) }', { a: 1 }], + [ + { + a: t.brand( + t.number, + (n: any): n is t.Branded => n >= 0, + 'Positive' + ) + }, + '{ a: Positive }', + { a: 1 } + ], + [{ a: t.keyof({ foo: null, bar: null }) }, '{ a: "foo" | "bar" }', { a: 'foo' }], + [ + { a: t.exact(t.type({ x: t.number, y: t.number })) }, + '{ a: {| x: number, y: number |} }', + { a: { x: 1, y: 2, z: 3 } } + ], + [{ a: t.strict({ x: t.number, y: t.number }) }, '{ a: {| x: number, y: number |} }', { a: { x: 1, y: 2, z: 3 } }] + ] + + describe('`is` should return `true` for', () => { + test.each(successCases)('props: %p, name: %p given valid input %j', (props, name, value) => { + const T = t.type(props, name) + assert.strictEqual(T.is(value), true) }) + }) - it('should return `false` on invalid inputs', () => { - const T = t.type({ a: t.string }) - assert.strictEqual(T.is({}), false) - assert.strictEqual(T.is({ a: 1 }), false) - // #407 - assert.strictEqual(T.is([]), false) + describe('`decode` should succeed decoding with', () => { + test.each(successCases)('props: %p, name: %p, value: %j', (props, name, value) => { + const T = t.type(props, name) + assertSuccess(T.decode(value)) + }) + }) + + const failureCases = [ + // [ props, name, value, messages ] + [{ a: t.null }, '{ a: null }', { a: 'a' }, ['Invalid value "a" supplied to : { a: null }/a: null']], + [{ a: t.null }, '{ a: null }', {}, ['Invalid value undefined supplied to : { a: null }/a: null']], + [{ a: t.nullType }, '{ a: null }', { a: undefined }, ['Invalid value undefined supplied to : { a: null }/a: null']], + [ + { a: t.undefined }, + '{ a: undefined }', + { a: 'a' }, + ['Invalid value "a" supplied to : { a: undefined }/a: undefined'] + ], + [ + { a: t.undefined }, + '{ a: undefined }', + {}, + ['Invalid value undefined supplied to : { a: undefined }/a: undefined'] + ], + [{ a: t.void }, '{ a: void }', { a: null }, ['Invalid value null supplied to : { a: void }/a: void']], + [{ a: t.unknown }, '{ a: unknown }', {}, ['Invalid value undefined supplied to : { a: unknown }/a: unknown']], + [{ a: t.string }, '{ a: string }', 1, ['Invalid value 1 supplied to : { a: string }']], + [{ a: t.string }, '{ a: string }', {}, ['Invalid value undefined supplied to : { a: string }/a: string']], + [ + { a: t.string }, + '{ a: string }', + { a: undefined }, + ['Invalid value undefined supplied to : { a: string }/a: string'] + ], + [{ a: t.string }, '{ a: string }', { a: 1 }, ['Invalid value 1 supplied to : { a: string }/a: string']], + [{ a: t.string }, '{ a: string }', [], ['Invalid value [] supplied to : { a: string }']], // #407 + [{ a: t.number }, '{ a: number }', { a: 'a' }, ['Invalid value "a" supplied to : { a: number }/a: number']], + [{ a: t.number }, '{ a: number }', {}, ['Invalid value undefined supplied to : { a: number }/a: number']], + [{ a: t.bigint }, '{ a: bigint }', { a: 'a' }, ['Invalid value "a" supplied to : { a: bigint }/a: bigint']], + [{ a: t.bigint }, '{ a: bigint }', {}, ['Invalid value undefined supplied to : { a: bigint }/a: bigint']], + [{ a: t.boolean }, '{ a: boolean }', { a: 1 }, ['Invalid value 1 supplied to : { a: boolean }/a: boolean']], + [{ a: t.boolean }, '{ a: boolean }', {}, ['Invalid value undefined supplied to : { a: boolean }/a: boolean']], + [ + { a: t.UnknownArray }, + '{ a: UnknownArray }', + { a: 'a' }, + ['Invalid value "a" supplied to : { a: UnknownArray }/a: UnknownArray'] + ], + [ + { a: t.UnknownArray }, + '{ a: UnknownArray }', + {}, + ['Invalid value undefined supplied to : { a: UnknownArray }/a: UnknownArray'] + ], + [ + { a: t.UnknownArray }, + '{ a: UnknownArray }', + { a: undefined }, + ['Invalid value undefined supplied to : { a: UnknownArray }/a: UnknownArray'] + ], + [ + { a: t.array(t.number) }, + '{ a: Array }', + { a: 1 }, + ['Invalid value 1 supplied to : { a: Array }/a: Array'] + ], + [ + { a: t.array(t.number) }, + '{ a: Array }', + {}, + ['Invalid value undefined supplied to : { a: Array }/a: Array'] + ], + [ + { a: t.UnknownRecord }, + '{ a: UnknownRecord }', + { a: [1] }, + ['Invalid value [1] supplied to : { a: UnknownRecord }/a: UnknownRecord'] + ], + [ + { a: t.UnknownRecord }, + '{ a: UnknownRecord }', + {}, + ['Invalid value undefined supplied to : { a: UnknownRecord }/a: UnknownRecord'] + ], + [ + { a: t.UnknownRecord }, + '{ a: UnknownRecord }', + { a: undefined }, + ['Invalid value undefined supplied to : { a: UnknownRecord }/a: UnknownRecord'] + ], + [ + { a: t.record(t.string, t.string) }, + '{ a: { [K in string]: string } }', + { a: 1 }, + ['Invalid value 1 supplied to : { a: { [K in string]: string } }/a: { [K in string]: string }'] + ], + [ + { a: t.record(t.string, t.string) }, + '{ a: { [K in string]: string } }', + {}, + ['Invalid value undefined supplied to : { a: { [K in string]: string } }/a: { [K in string]: string }'] + ], + [{ a: t.Int }, '{ a: Int }', { a: -1.1 }, ['Invalid value -1.1 supplied to : { a: Int }/a: Int']], + [{ a: t.Int }, '{ a: Int }', {}, ['Invalid value undefined supplied to : { a: Int }/a: Int']], + [{ a: t.literal('YES') }, '{ a: "YES" }', { a: 'NO' }, ['Invalid value "NO" supplied to : { a: "YES" }/a: "YES"']], + [{ a: t.literal('YES') }, '{ a: "YES" }', {}, ['Invalid value undefined supplied to : { a: "YES" }/a: "YES"']], + [ + { a: t.partial({ b: t.string, c: t.string }) }, + '{ a: Partial<{ b: string, c: string }> }', + { a: { b: 1 } }, + [ + 'Invalid value 1 supplied to : ' + + '{ a: Partial<{ b: string, c: string }> }/a: Partial<{ b: string, c: string }>/b: string' + ] + ], + [ + { a: t.partial({ b: t.string, c: t.string }) }, + '{ a: Partial<{ b: string, c: string }> }', + {}, + [ + 'Invalid value undefined supplied to : ' + + '{ a: Partial<{ b: string, c: string }> }/a: Partial<{ b: string, c: string }>' + ] + ], + [ + { a: t.readonly(t.number) }, + '{ a: Readonly }', + { a: 'a' }, + ['Invalid value "a" supplied to : { a: Readonly }/a: Readonly'] + ], + [ + { a: t.readonly(t.number) }, + '{ a: Readonly }', + {}, + ['Invalid value undefined supplied to : { a: Readonly }/a: Readonly'] + ], + [ + { a: t.readonlyArray(t.number) }, + '{ a: ReadonlyArray }', + { a: 1 }, + ['Invalid value 1 supplied to : { a: ReadonlyArray }/a: ReadonlyArray'] + ], + [ + { a: t.readonlyArray(t.number) }, + '{ a: ReadonlyArray }', + {}, + ['Invalid value undefined supplied to : { a: ReadonlyArray }/a: ReadonlyArray'] + ], + [ + { a: t.tuple([t.string, t.string]) }, + '{ a: [string, string] }', + { a: [1, 2] }, + [ + 'Invalid value 1 supplied to : { a: [string, string] }/a: [string, string]/0: string', + 'Invalid value 2 supplied to : { a: [string, string] }/a: [string, string]/1: string' + ] + ], + [ + { a: t.tuple([t.string, t.string]) }, + '{ a: [string, string] }', + {}, + ['Invalid value undefined supplied to : { a: [string, string] }/a: [string, string]'] + ], + [ + { a: t.union([t.string, t.number]), b: t.union([t.string, t.number]) }, + '{ a: (string | number), b: (string | number) }', + { a: [1], b: ['b'] }, + [ + 'Invalid value [1] supplied to : { a: (string | number), b: (string | number) }/a: (string | number)/0: string', + 'Invalid value [1] supplied to : { a: (string | number), b: (string | number) }/a: (string | number)/1: number', + 'Invalid value ["b"] supplied to : { a: (string | number), b: (string | number) }/b: (string | number)/0: string', + 'Invalid value ["b"] supplied to : { a: (string | number), b: (string | number) }/b: (string | number)/1: number' + ] + ], + [ + { a: t.union([t.string, t.number]), b: t.union([t.string, t.number]) }, + '{ a: (string | number), b: (string | number) }', + {}, + [ + 'Invalid value undefined supplied to : { a: (string | number), b: (string | number) }/a: (string | number)', + 'Invalid value undefined supplied to : { a: (string | number), b: (string | number) }/b: (string | number)' + ] + ], + [ + { a: t.intersection([t.number, t.Int]) }, + '{ a: (number & Int) }', + { a: 'a' }, + [ + 'Invalid value "a" supplied to : { a: (number & Int) }/a: (number & Int)/0: number', + 'Invalid value "a" supplied to : { a: (number & Int) }/a: (number & Int)/1: Int' + ] + ], + [ + { a: t.intersection([t.number, t.Int]) }, + '{ a: (number & Int) }', + {}, + ['Invalid value undefined supplied to : { a: (number & Int) }/a: (number & Int)'] + ], + [ + { a: t.brand(t.number, (n): n is t.Branded => n >= 0, 'Positive') }, + '{ a: Positive }', + { a: 'a' }, + ['Invalid value "a" supplied to : { a: Positive }/a: Positive'] + ], + [ + { a: t.brand(t.number, (n): n is t.Branded => n >= 0, 'Positive') }, + '{ a: Positive }', + {}, + ['Invalid value undefined supplied to : { a: Positive }/a: Positive'] + ], + [ + { a: t.keyof({ foo: null, bar: null }) }, + '{ a: "foo" | "bar" }', + { a: 'baz' }, + ['Invalid value "baz" supplied to : { a: "foo" | "bar" }/a: "foo" | "bar"'] + ], + [ + { a: t.keyof({ foo: null, bar: null }) }, + '{ a: "foo" | "bar" }', + {}, + ['Invalid value undefined supplied to : { a: "foo" | "bar" }/a: "foo" | "bar"'] + ], + [ + { a: t.exact(t.type({ x: t.number, y: t.number })) }, + '{ a: {| x: number, y: number |} }', + { a: { x: 1, z: 3 } }, + [ + 'Invalid value undefined supplied to : ' + + '{ a: {| x: number, y: number |} }/a: {| x: number, y: number |}/y: number' + ] + ], + [ + { a: t.strict({ x: t.number, y: t.number }) }, + '{ a: {| x: number, y: number |} }', + {}, + ['Invalid value undefined supplied to : ' + '{ a: {| x: number, y: number |} }/a: {| x: number, y: number |}'] + ] + ] + + describe('`is` should return `false` for', () => { + test.each(failureCases)('props: %p, name: %p, value: %j', (props, name, value) => { + const T = t.type(props, name) + assert.strictEqual(T.is(value), false) + }) + }) + + describe('`decode` should fail decoding with', () => { + test.each(failureCases)('props: %p, name: %p, value: %j', (props, name, value, messages) => { + const T = t.type(props, name) + assertFailure(T, value, messages) }) + }) + describe('is', () => { // #434 it('should return `false` on missing fields', () => { const T = t.type({ a: t.unknown }) @@ -42,6 +350,26 @@ describe('type', () => { assert.strictEqual(T.is({ a: 'a', b: 1 }), true) }) + it('should handle recursive types properly', () => { + interface Tree { + name: string + children?: Array | undefined + } + + const Tree: t.Type = t.recursion('Tree', () => + t.intersection([t.type({ name: t.string }), t.partial({ children: t.union([t.array(Tree), t.undefined]) })]) + ) + + const subtree: Tree = { name: 'subtree', children: [] } + const childlessSubtree: Tree = { name: 'childless' } + const invalidSubtree = { name: 'invalid', children: 'children ' } + + assert.strictEqual(Tree.is({ name: 'a', children: [subtree] }), true) + assert.strictEqual(Tree.is({ name: 'b', children: [childlessSubtree] }), true) + assert.strictEqual(Tree.is({ name: 'c' }), true) + assert.strictEqual(Tree.is({ name: 'd', children: [invalidSubtree] }), false) + }) + it('#423', () => { class A { get a() { @@ -67,27 +395,19 @@ describe('type', () => { assertSuccess(T.decode({ a: '1' }), { a: 1 }) }) - it('should decode undefined properties as always present keys', () => { + it('should decode undefined properties', () => { const T1 = t.type({ a: t.undefined }) assertSuccess(T1.decode({ a: undefined }), { a: undefined }) - assertSuccess(T1.decode({}), { a: undefined }) const T2 = t.type({ a: t.union([t.number, t.undefined]) }) assertSuccess(T2.decode({ a: undefined }), { a: undefined }) assertSuccess(T2.decode({ a: 1 }), { a: 1 }) - assertSuccess(T2.decode({}), { a: undefined }) const T3 = t.type({ a: t.unknown }) - assertSuccess(T3.decode({}), { a: undefined }) - }) + assertSuccess(T3.decode({ a: undefined }), { a: undefined }) - it('should fail decoding an invalid value', () => { - const T = t.type({ a: t.string }) - assertFailure(T, 1, ['Invalid value 1 supplied to : { a: string }']) - assertFailure(T, {}, ['Invalid value undefined supplied to : { a: string }/a: string']) - assertFailure(T, { a: 1 }, ['Invalid value 1 supplied to : { a: string }/a: string']) - // #407 - assertFailure(T, [], ['Invalid value [] supplied to : { a: string }']) + const T4 = t.type({ a: t.void }) + assertSuccess(T4.decode({ a: undefined }), { a: undefined }) }) it('should support the alias `interface`', () => {