From 97ac191ed9ccf37eaf8d369a7a39e2b0e78b8543 Mon Sep 17 00:00:00 2001 From: Kejing Han Date: Sun, 30 Oct 2022 22:24:41 +0800 Subject: [PATCH 1/3] Add optional properties to type. --- src/index.ts | 81 +++++++++++- test/2.1.x/optional.ts | 274 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 test/2.1.x/optional.ts diff --git a/src/index.ts b/src/index.ts index 2434a683..2276e78a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -265,7 +265,12 @@ export interface AnyProps { function getNameFromProps(props: Props): string { return Object.keys(props) - .map((k) => `${k}: ${props[k].name}`) + .map((k) => { + if (isOptional(props[k])) { + return `${k}?: ${props[k].name}` + } + return `${k}: ${props[k].name}` + }) .join(', ') } @@ -666,6 +671,10 @@ function isRecursiveC(codec: Any): codec is RecursiveType { return (codec as any)._tag === 'RecursiveType' } +function isOptional(codec: Any): codec is OptionalType { + return (codec as any)._tag === 'OptionalType' +} + const lazyCodecs: Array = [] /** @@ -1320,11 +1329,29 @@ export class InterfaceType extends Type } } +type TypeOfWithOptionalProps

= ( + { + [K in keyof P]?: TypeOf + } & + { + [K in keyof P as P[K] extends OptionalType ? never : K]-?: TypeOf + } +) + +type OutputOfWithOptionalProps

= ( + { + [K in keyof P]?: OutputOf + } & + { + [K in keyof P as P[K] extends OptionalType ? never : K]-?: OutputOf + } +) + /** * @since 1.5.3 */ export interface TypeC

- extends InterfaceType }, { [K in keyof P]: OutputOf }, unknown> {} + extends InterfaceType, OutputOfWithOptionalProps

, unknown> {} /** * @category combinators @@ -1341,7 +1368,9 @@ export function type

(props: P, name: string = getInterfaceTypeN 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 (uk === undefined && !hasOwnProperty.call(u, k)) { + return isOptional(props[k]) + } else if (!types[i].is(uk)) { return false } } @@ -1366,7 +1395,7 @@ export function type

(props: P, name: string = getInterfaceTypeN pushAll(errors, result.left) } else { const vak = result.right - if (vak !== ak || (vak === undefined && !hasOwnProperty.call(a, k))) { + if (vak !== ak || (vak === undefined && !hasOwnProperty.call(a, k) && !isOptional(type))) { /* istanbul ignore next */ if (a === o) { a = { ...o } @@ -1384,8 +1413,9 @@ export function type

(props: P, name: string = getInterfaceTypeN for (let i = 0; i < len; i++) { const k = keys[i] const encode = types[i].encode - if (encode !== identity) { - s[k] = encode(a[k]) + const av = a[k] + if (encode !== identity && (av !== undefined || !isOptional(types[i]))) { + s[k] = encode(av) } } return s as any @@ -1394,6 +1424,45 @@ export function type

(props: P, name: string = getInterfaceTypeN ) } +export class OptionalType extends Type { + constructor(name: string, + is: OptionalType['is'], + validate: OptionalType['validate'], + encode: OptionalType['encode'], + codec: Type) { + super(name, is, validate, encode) + this._type = codec + } + + readonly _type: Type + readonly _tag: 'OptionalType' = 'OptionalType' +} + +export function optional( + codec: Type, + name?: string +): OptionalType { + return new OptionalType( + name || codec.name, + codec.is, + (u, c) => { + if (u === undefined) { + return success(undefined as A) + } + return codec.validate(u, c) + }, + codec.encode === identity + ? identity + : (u) => { + if (u === undefined) { + return undefined as any + } + return codec.encode(u) + }, + codec + ) +} + /** * @since 1.0.0 */ diff --git a/test/2.1.x/optional.ts b/test/2.1.x/optional.ts new file mode 100644 index 00000000..025b63fa --- /dev/null +++ b/test/2.1.x/optional.ts @@ -0,0 +1,274 @@ +import * as assert from 'assert' +import { fold } from 'fp-ts/lib/Either' +import { pipe } from 'fp-ts/lib/pipeable' +import * as t from '../../src/index' +import { assertFailure, assertStrictEqual, assertSuccess, NumberFromString, withDefault } from './helpers' + +describe('type', () => { + describe('name', () => { + it('should assign a default name', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.name, '{ a: string, b?: string }') + }) + + it('should accept a name', () => { + const T = t.type({ a: t.string }, 'T') + assert.strictEqual(T.name, 'T') + }) + }) + + describe('is', () => { + it('should return `true` on valid inputs', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.is({ a: 'a' }), true) + assert.strictEqual(T.is({ a: 'a', b: 'b' }), true) + }) + + it('should return `false` on invalid inputs', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.is({}), false) + assert.strictEqual(T.is({ a: 1 }), false) + assert.strictEqual(T.is({ b: 'b' }), false) + assert.strictEqual(T.is([]), false) + }) + + it('should return `false` on missing fields', () => { + const T = t.type({ a: t.unknown }) + assert.strictEqual(T.is({}), false) + }) + + it('should allow additional properties', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.is({ a: 'a', b: 'b', c: 'c' }), true) + }) + + it('should work for classes with getters', () => { + class A { + get a() { + return 'a' + } + get b() { + return 'b' + } + } + class B { + get a() { + return 'a' + } + } + class C { + get b() { + return 'b' + } + } + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.is(new A()), true) + assert.strictEqual(T.is(new B()), true) + assert.strictEqual(T.is(new C()), false) + }) + }) + + describe('decode', () => { + it('should decode a isomorphic value', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assertSuccess(T.decode({ a: 'a' })) + assertSuccess(T.decode({ a: 'a', b: 'b' })) + }) + + it('should decode a prismatic value', () => { + const T = t.type({ a: NumberFromString, b: t.optional(NumberFromString) }) + assertSuccess(T.decode({ a: '1' }), { a: 1 }) + assertSuccess(T.decode({ a: '1', b: '2' }), { a: 1, b: 2 }) + }) + + it('should decode undefined properties as always present keys when required', () => { + 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 }) + }) + + it('should decode undefined properties as missing keys when optional and omitted', () => { + const T1 = t.type({ a: t.optional(t.undefined) }) + assertSuccess(T1.decode({ a: undefined }), { a: undefined }) + assertSuccess(T1.decode({}), {}) + + const T2 = t.type({ a: t.optional(t.union([t.number, t.undefined])) }) + assertSuccess(T2.decode({ a: undefined }), { a: undefined }) + assertSuccess(T2.decode({ a: 1 }), { a: 1 }) + assertSuccess(T2.decode({}), {}) + + const T3 = t.type({ a: t.optional(t.unknown) }) + assertSuccess(T3.decode({}), {}) + }) + + it('should fail decoding an invalid value', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assertFailure(T, 1, ['Invalid value 1 supplied to : { a: string, b?: string }']) + assertFailure(T, {}, ['Invalid value undefined supplied to : { a: string, b?: string }/a: string']) + assertFailure(T, { a: 1 }, ['Invalid value 1 supplied to : { a: string, b?: string }/a: string']) + assertFailure(T, [], ['Invalid value [] supplied to : { a: string, b?: string }']) + }) + + it('#423', () => { + class A { + get a() { + return 'a' + } + get b() { + return 'b' + } + } + const T = t.type({ a: t.string, b: t.string }) + assertSuccess(T.decode(new A())) + }) + + it('should support default values', () => { + const T = t.type({ + name: withDefault(t.string, 'foo') + }) + assertSuccess(T.decode({}), { name: 'foo' }) + assertSuccess(T.decode({ name: 'a' }), { name: 'a' }) + }) + }) + + describe('encode', () => { + it('should encode a isomorphic value', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.deepStrictEqual(T.encode({ a: 'a' }), { a: 'a' }) + assert.deepStrictEqual(T.encode({ a: 'a', b: 'b' }), { a: 'a', b: 'b' }) + }) + + it('should encode a prismatic value', () => { + const T = t.type({ a: NumberFromString, b: t.optional(NumberFromString) }) + assert.deepStrictEqual(T.encode({ a: 1 }), { a: '1' }) + assert.deepStrictEqual(T.encode({ a: 1, b: 2 }), { a: '1', b: '2' }) + }) + }) + + it('should keep unknown properties', () => { + const T = t.type({ a: t.string }) + const validation = T.decode({ a: 's', b: 1 }) + pipe( + validation, + fold( + () => { + assert.ok(false) + }, + (a) => { + assert.deepStrictEqual(a, { a: 's', b: 1 }) + } + ) + ) + }) + + it('should return the same reference if validation succeeded and nothing changed', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + const value1 = { a: 's' } + assertStrictEqual(T.decode(value1), value1) + const value2 = { a: 's', b: 't' } + assertStrictEqual(T.decode(value2), value2) + }) + + it('should return the same reference while encoding', () => { + const T = t.type({ a: t.string, b: t.optional(t.string) }) + assert.strictEqual(T.encode, t.identity) + }) + + it('should work for empty object', () => { + const T = t.type({}) + assert.deepStrictEqual(T.encode({}), {}) + assert.deepStrictEqual(T.decode({}), t.success({})) + assert.strictEqual(T.is({}), true) + }) + + it('should work for an object with all required properties', () => { + const type = t.type({ + p1: t.string, + p2: t.string, + p3: t.optional(t.string) + }) + const obj = { + p1: 'p1', + p2: 'p2', + p3: 'p3' + } + assert.deepStrictEqual(type.encode(obj), { + p1: 'p1', + p2: 'p2', + p3: 'p3' + }) + assert.deepStrictEqual( + type.decode(obj), + t.success({ + p1: 'p1', + p2: 'p2', + p3: 'p3' + }) + ) + assert.strictEqual(type.is(obj), true) + assert.strictEqual(type.is({}), false) + assert.strictEqual(type.is({ p1: 'p1' }), false) + }) + + it('should work for an object with all optional properties', () => { + const type = t.type( + { + p1: t.optional(t.string), + p2: t.optional(t.string) + }, + 'Required' + ) + const obj = { + p1: 'p1', + p2: 'p2' + } + assert.deepStrictEqual(type.encode(obj), { + p1: 'p1', + p2: 'p2' + }) + assert.deepStrictEqual( + type.decode(obj), + t.success({ + p1: 'p1', + p2: 'p2' + }) + ) + assert.strictEqual(type.is(obj), true) + assert.strictEqual(type.is({}), true) + assert.strictEqual(type.is({ p1: 'p1' }), true) + }) + + it('should work for an object with a mix of optional and required props', () => { + const type = t.type( + { + p1: t.string, + p2: t.optional(t.string), + p3: t.string, + p4: t.optional(t.string) + }, + 'Required' + ) + const obj = { + p1: 'p1', + p2: 'p2', + p3: 'p3', + p4: 'p4' + } + expect(type.encode(obj)).toEqual(obj) + expect(type.decode(obj)).toEqual(t.success(obj)) + assert.strictEqual(type.is(obj), true) + assert.strictEqual(type.is({}), false) + assert.strictEqual(type.is({ p1: 'p1', p2: 'p2' }), false) + assert.strictEqual(type.is({ p2: 'p2', p4: 'p4' }), false) + assert.strictEqual(type.is({ p1: 'p1', p3: 'p3' }), true) + assert.strictEqual(type.is({ p1: 'p1', p2: 'p2', p3: 'p3' }), true) + }) +}) From fb8daf0bab859847c498b12a7245f36da2bbb4e5 Mon Sep 17 00:00:00 2001 From: Kejing Han Date: Sun, 30 Oct 2022 22:43:22 +0800 Subject: [PATCH 2/3] Update to fix prettier error. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f799159..c1033c26 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "import-path-rewrite": "github:gcanti/import-path-rewrite", "jest": "25.2.7", "mocha": "7.1.1", - "prettier": "2.0.2", + "prettier": "^2.7.1", "rimraf": "3.0.2", "ts-jest": "25.3.1", "ts-node": "8.8.2", From 013033400c8b8d848479578034d2326d9671cd25 Mon Sep 17 00:00:00 2001 From: Kejing Han Date: Sun, 30 Oct 2022 23:22:41 +0800 Subject: [PATCH 3/3] fix UT. --- src/index.ts | 13 +++---------- test/2.1.x/strictInterfaceWithOptionals.ts | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2276e78a..e9e4d78e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1329,7 +1329,7 @@ export class InterfaceType extends Type } } -type TypeOfWithOptionalProps

= ( +export type TypeOfWithOptionalProps

= ( { [K in keyof P]?: TypeOf } & @@ -1338,7 +1338,7 @@ type TypeOfWithOptionalProps

= ( } ) -type OutputOfWithOptionalProps

= ( +export type OutputOfWithOptionalProps

= ( { [K in keyof P]?: OutputOf } & @@ -1451,14 +1451,7 @@ export function optional( } return codec.validate(u, c) }, - codec.encode === identity - ? identity - : (u) => { - if (u === undefined) { - return undefined as any - } - return codec.encode(u) - }, + codec.encode, codec ) } diff --git a/test/2.1.x/strictInterfaceWithOptionals.ts b/test/2.1.x/strictInterfaceWithOptionals.ts index 50e9219a..c1c2f2ae 100644 --- a/test/2.1.x/strictInterfaceWithOptionals.ts +++ b/test/2.1.x/strictInterfaceWithOptionals.ts @@ -6,7 +6,7 @@ export function strictInterfaceWithOptionals & t.TypeOfPartialProps, t.OutputOfProps & t.OutputOfPartialProps> { +): t.Type & t.TypeOfPartialProps, t.OutputOfWithOptionalProps & t.OutputOfPartialProps> { return t.exact(t.intersection([t.type(required), t.partial(optional)]), name) }