From 1744f6278b8eab9b8cb2092e750f9bd42e4edd56 Mon Sep 17 00:00:00 2001 From: Erik Soehnel Date: Wed, 6 Dec 2023 16:39:48 +0100 Subject: [PATCH] fix impure records with optional fields fixes #100 --- CHANGELOG.md | 2 ++ src/optional.ts | 2 +- src/record.ts | 8 ++++++++ src/runtype.ts | 3 +++ test/helpers.ts | 5 +++-- test/issues.test.ts | 30 ++++++++++++++++++++++++++++++ test/record.test.ts | 15 +++++++++++++++ 7 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 test/issues.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0abf683..96f10b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - replaced by `nonStrict`: `sloppyRecord(X)` -> `nonStrict(record(X))` - fixes [#23](https://github.com/hoeck/simple-runtypes/issues/23) - fix: allow literal booleans as type-discriminators in discriminated unions, see [#98](https://github.com/hoeck/simple-runtypes/issues/98) +- fix: `string` trim & maxlength checks: https://github.com/hoeck/simple-runtypes/issues/100 +- fix: `record` optionals: https://github.com/hoeck/simple-runtypes/issues/100 ### 7.1.3 diff --git a/src/optional.ts b/src/optional.ts index 19d7a68..d2e50f9 100644 --- a/src/optional.ts +++ b/src/optional.ts @@ -25,6 +25,6 @@ export function optional(t: Runtype): OptionalRuntype { return ti(v, failOrThrow) }, - { isPure: ti.meta?.isPure }, + { isPure: ti.meta?.isPure, optional: true }, ) as OptionalRuntype } diff --git a/src/record.ts b/src/record.ts index f3cb149..3cf89ca 100644 --- a/src/record.ts +++ b/src/record.ts @@ -54,6 +54,14 @@ export function internalRecord( const k = typemapKeys[i] const t = typemapValues[i] as InternalRuntype + // optional fields not present in the given object do not need to be + // checked at all + // this is vital to preserve the object shape of an impure record + // with optional fields + if (t.meta?.optional && !o.hasOwnProperty(k)) { + break + } + // nested types should always fail with explicit `Fail` so we can add additional data const value = t(o[k], failSymbol) diff --git a/src/runtype.ts b/src/runtype.ts index efcb626..789fe7a 100644 --- a/src/runtype.ts +++ b/src/runtype.ts @@ -184,6 +184,9 @@ export interface RuntypeMetadata { // literal value used to identify tagged union members literal?: string | boolean | number + + // when true, this runtype is an optional in a record + optional?: boolean } /** diff --git a/test/helpers.ts b/test/helpers.ts index 0728ae1..90fa451 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -16,7 +16,8 @@ export function expectAcceptValuesImpure( const [vIn, vOut] = valuesAreTuples ? (v as [unknown, unknown]) : [v, v] const result = st.use(rt, vIn) - expect(result).toEqual({ ok: true, result: vOut }) + expect(result).toStrictEqual({ ok: true, result: vOut }) + // this identity check does not account for unmodified primitive values, // which are always identical to themselves; these cases are handled // directly in the relevant tests @@ -39,7 +40,7 @@ export function expectAcceptValuesPure( values.forEach((v) => { const result = st.use(rt, v) - expect(result).toEqual({ ok: true, result: v }) + expect(result).toStrictEqual({ ok: true, result: v }) expect(result.ok && result.result).toBe(v) expect(() => rt(v)).not.toThrow() diff --git a/test/issues.test.ts b/test/issues.test.ts new file mode 100644 index 0000000..36eb612 --- /dev/null +++ b/test/issues.test.ts @@ -0,0 +1,30 @@ +import { st } from './helpers' + +describe('github issues', () => { + it('#100 (1)', () => { + expect( + st.use( + st.partial( + st.record({ name: st.string({ trim: false }), other: st.string() }), + ), + {}, + ), + ).toEqual({ ok: true, result: {} }) + + expect( + st.use( + st.partial( + st.record({ name: st.string({ trim: true }), other: st.string() }), + ), + {}, + ), + ).toEqual({ ok: true, result: {} }) + }) + + it('#100 (2)', () => { + const s = st.string({ trim: true, minLength: 1, maxLength: 3 }) + + expect(() => s(' ')).toThrow() + expect(s('abc ')).toEqual('abc') + }) +}) diff --git a/test/record.test.ts b/test/record.test.ts index 20326e1..acfbd94 100644 --- a/test/record.test.ts +++ b/test/record.test.ts @@ -56,6 +56,21 @@ describe('record', () => { ]) }) + it('keeps optional attributes of impure records', () => { + const runtype = st.record({ a: st.optional(st.string({ trim: true })) }) + + expectAcceptValuesImpure( + runtype, + [ + [{}, {}], + [{ a: '' }, { a: '' }], + [{ a: ' a ' }, { a: 'a' }], + [{ a: undefined }, { a: undefined }], + ], + true, + ) + }) + it('accepts nested records', () => { const runtype = st.record({ a: st.record({