diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0f0f0..81eb928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +### Unreleased + +- add `nonStrict` combinator ([#90](https://github.com/hoeck/simple-runtypes/pull/90)) + - `nonStrict` creates a non-strict `record` runtype from a provided `record` runtype + - a non-strict `record` runtype will ignore keys that are not specified in the original runtype's typemap + - non-strict `record` runtypes will replace `sloppyRecord` runtypes + ### 7.1.3 - fix: invalid key message was displaying the object instead of the keys, see [#91](https://github.com/hoeck/simple-runtypes/issues/91) diff --git a/README.md b/README.md index c0531df..23fd2e3 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ That's how runtypes work. - [Documentation](#documentation) * [Intro](#intro) * [Usage Examples](#usage-examples) + + [Nesting](#nesting) + [Strict Property Checks](#strict-property-checks) + + [Ignore Individual Properties](#ignore-individual-properties) + [Optional Properties](#optional-properties) - + [Nesting](#nesting) + + [Non-strict Property Checks](#non-strict-property-checks) + [Discriminating Unions](#discriminating-unions) + [Custom Runtypes](#custom-runtypes) * [Reference](#reference) @@ -30,7 +32,13 @@ That's how runtypes work. ## Install -`npm install simple-runtypes` or `yarn add simple-runtypes` +```sh +# npm +npm install simple-runtypes + +# yarn +yarn add simple-runtypes +``` ## Example @@ -40,9 +48,9 @@ That's how runtypes work. import * as st from 'simple-runtypes' const userRuntype = st.record({ - id: st.integer(), - name: st.string(), - email: st.optional(st.string()), + id: st.integer(), + name: st.string(), + email: st.optional(st.string()), }) ``` @@ -50,29 +58,29 @@ now, `ReturnType` is equivalent to ```typescript interface { - id: number, - name: string, - email?: string + id: number + name: string + email?: string } ``` 2. Use the runtype to validate untrusted data ```typescript -userRuntype({id: 1, name: 'matt'}) +userRuntype({ id: 1, name: 'matt' }) // => {id: 1, name: 'matt'} -userRuntype({id: 1, name: 'matt', isAdmin: true}) +userRuntype({ id: 1, name: 'matt', isAdmin: true }) // throws an st.RuntypeError: "invalid field 'isAdmin' in data" ``` Invoke a runtype with [`use`](src/custom.ts#L51) to get a plain value back instead of throwing errors: ```typescript -st.use(userRuntype, {id: 1, name: 'matt'}) +st.use(userRuntype, { id: 1, name: 'matt' }) // => {ok: true, result: {id: 1, name: 'matt'}} -st.use(userRuntype, {id: 1, name: 'matt', isAdmin: true}) +st.use(userRuntype, { id: 1, name: 'matt', isAdmin: true }) // => {ok: false, error: FAIL} st.getFormattedError(FAIL) @@ -86,10 +94,10 @@ Throwing errors and catching them outside is more convenient. Why should I use this over the plethora of [other](https://github.com/moltar/typescript-runtime-type-benchmarks#packages-compared) runtype validation libraries available? -1. Strict: by default safe against proto injection attacks and unwanted properties -2. Fast: check the [benchmark](https://github.com/moltar/typescript-runtime-type-benchmarks) -3. Friendly: no use of `eval`, a small [footprint](https://bundlephobia.com/result?p=simple-runtypes) and no dependencies -4. Flexible: optionally modify the data while it's being checked: trim strings, convert numbers, parse dates +1. **Strict**: by default safe against `__proto__` injection attacks and unwanted properties +2. **Fast**: check the [benchmark](https://github.com/moltar/typescript-runtime-type-benchmarks) +3. **Friendly**: no use of `eval`, and a small [footprint](https://bundlephobia.com/result?p=simple-runtypes) with no dependencies +4. **Flexible**: optionally modify the data while it's being checked - trim strings, convert numbers, parse dates ## Benchmarks @@ -110,7 +118,7 @@ A [`Runtype`](src/runtype.ts#L106) is a function that: ```typescript interface Runtype { - (v: unknown) => T + (v: unknown) => T } ``` @@ -120,62 +128,66 @@ Check the factory functions documentation for more details. ### Usage Examples -#### Strict Property Checks +#### Nesting -When using [`record`](src/record.ts#L134), any properties which are not defined in the runtype will cause the runtype to fail: +Collection runtypes such as [`record`](src/record.ts#L141), [`array`](src/array.ts#L28), and [`tuple`](src/tuple.ts#L42) take runtypes as their parameters: ```typescript -const strict = st.record({name: st.string()}) +const nestedRuntype = st.record({ + name: st.string(), + items: st.array(st.record({ id: st.integer, label: st.string() })), +}) -strict({name: 'foo', other: 123}) -// => RuntypeError: Unknown attribute 'other' +nestedRuntype({ + name: 'foo', + items: [{ id: 3, label: 'bar' }], +}) // => returns the same data ``` -To ignore single properties, use [`ignore`](src/ignore.ts#L6), [`unknown`](src/unknown.ts#L6) or [`any`](src/any.ts#L6): +#### Strict Property Checks + +When using [`record`](src/record.ts#L141), any properties which are not defined in the runtype will cause the runtype to fail: ```typescript -const strict = st.record({name: st.string(), other: st.ignore()}) +const strict = st.record({ name: st.string() }) -strict({name: 'foo', other: 123}) -// => {name: foo, other: undefined} +strict({ name: 'foo', other: 123 }) +// => RuntypeError: Unknown attribute 'other' ``` -Use [`sloppyRecord`](src/record.ts#L159) to only validate known properties and remove everything else: +Using [`record`](src/record.ts#L141) will keep you safe from any `__proto__` injection or overriding attempts. + +#### Ignore Individual Properties + +To ignore individual properties, use [`ignore`](src/ignore.ts#L6), [`unknown`](src/unknown.ts#L6) or [`any`](src/any.ts#L6): ```typescript -const sloppy = st.sloppyRecord({name: st.string()}) +const strict = st.record({ name: st.string(), other: st.ignore() }) -sloppy({name: 'foo', other: 123, bar: []}) -// => {name: foo} +strict({ name: 'foo', other: 123 }) +// => {name: foo, other: undefined} ``` -Using any of [`record`](src/record.ts#L134) or [`sloppyRecord`](src/record.ts#L159) will keep you safe from any `__proto__` injection or overriding attempts. - #### Optional Properties Use the [`optional`](src/optional.ts#L18) runtype to create [optional properties](https://www.typescriptlang.org/docs/handbook/interfaces.html#optional-properties): ```typescript -const squareConfigRuntype = st.record({ +const strict = st.record({ color: st.optional(st.string()), - width?: st.optional(st.number()), + width: st.optional(st.number()), }) ``` -#### Nesting +#### Non-strict Property Checks -Collection runtypes such as [`record`](src/record.ts#L134), [`array`](src/array.ts#L28), [`tuple`](src/tuple.ts#L42) take runtypes as their parameters: +Use [`nonStrict`](src/nonStrict.ts#L16) to only validate known properties and remove everything else: ```typescript -const nestedRuntype = st.record({ - name: st.string(), - items: st.array(st.record({ id: st.integer, label: st.string() })), -}) +const nonStrictRecord = st.nonStrict(st.record({ name: st.string() })) -nestedRuntype({ - name: 'foo', - items: [{ id: 3, label: 'bar' }], -}) // => returns the same data +nonStrictRecord({ name: 'foo', other: 123, bar: [] }) +// => {name: foo} ``` #### Discriminating Unions @@ -219,16 +231,16 @@ Finding the runtype to validate a specific discriminating union with is done eff Write your own runtypes as plain functions, e.g. if you want to turn a string into a `BigInt`: ```typescript -const bigIntStringRuntype = st.string({match: /^-?[0-9]+n$/}) +const bigIntStringRuntype = st.string({ match: /^-?[0-9]+n$/ }) const bigIntRuntype = st.runtype((v) => { - const stringCheck = st.use(bigIntStringRuntype, v) + const stringCheck = st.use(bigIntStringRuntype, v) - if (!stringCheck.ok) { - return stringCheck.error - } + if (!stringCheck.ok) { + return stringCheck.error + } - return BigInt(stringCheck.result.slice(0, -1)) + return BigInt(stringCheck.result.slice(0, -1)) }) bigIntRuntype("123n") // => 123n @@ -260,9 +272,8 @@ Objects and Array Runtypes: - [`tuple`](src/tuple.ts#L42) - [`array`](src/array.ts#L28) -- [`record`](src/record.ts#L134) +- [`record`](src/record.ts#L141) - [`optional`](src/optional.ts#L18) -- [`sloppyRecord`](src/record.ts#L159) - [`dictionary`](src/dictionary.ts#L87) Combinators: @@ -272,6 +283,7 @@ Combinators: - [`omit`](src/omit.ts#L8) - [`pick`](src/pick.ts#L7) - [`partial`](src/partial.ts#L10) +- [`nonStrict`](src/nonStrict.ts#L16) - TODO: `get` - similar to Type[key] Shortcuts: @@ -285,7 +297,6 @@ Shortcuts: - rename [`stringLiteralUnion`](src/stringLiteralUnion.ts#L6) to `literals` or `literalUnion` and make it work on all types that [`literal`](src/literal.ts#L10) accepts - rename record to object: [#69](https://github.com/hoeck/simple-runtypes/issues/69) -- nonStrict modifier instead of sloppy: [#68](https://github.com/hoeck/simple-runtypes/issues/68) - improve docs: - *preface*: what is a runtype and why is it useful - *why*: explain or link to example that shows "strict by default" @@ -295,5 +306,5 @@ Shortcuts: - add small frontend and backend example projects that show how to use `simple-runtypes` in production - test *all* types with [tsd](https://github.com/SamVerschueren/tsd) - add more combinators: partial, required, get, ... -- separate `Runtype` and `InternalRuntype` and type runtype internals +- separate [`Runtype`](src/runtype.ts#L106) and [`InternalRuntype`](src/runtype.ts#L171) and type runtype internals (see [this comment](https://github.com/hoeck/simple-runtypes/pull/73#discussion_r948841977)) diff --git a/src/index.ts b/src/index.ts index bbbe159..ee299eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export { dictionary } from './dictionary' // advanced / utility types export { intersection } from './intersection' +export { nonStrict } from './nonStrict' export { omit } from './omit' export { partial } from './partial' export { pick } from './pick' diff --git a/src/nonStrict.ts b/src/nonStrict.ts new file mode 100644 index 0000000..2312c9f --- /dev/null +++ b/src/nonStrict.ts @@ -0,0 +1,24 @@ +import { internalRecord } from './record' +import { Runtype, RuntypeUsageError } from './runtype' + +/** + * Build a non-strict `record` runtype from the provided `record` runtype. + * + * In contrast to a `record` runtype, a non-strict `record` runtype will ignore + * keys that are not specified in the original runtype's typemap (non-strict checking). + * + * When a non-strict `record` runtype checks an object, it will return a new + * object that contains only the keys specified in the original runtype's typemap. + * + * Non-strict checking only applies to the root typemap. To apply non-strict checking + * to a nested typemap, `nonStrict` needs to be used at each level of the typemap. + */ +export function nonStrict(original: Runtype): Runtype { + const fields = (original as any).fields + + if (!fields) { + throw new RuntypeUsageError('expected a record runtype') + } + + return internalRecord(fields, true) +} diff --git a/src/omit.ts b/src/omit.ts index e8620fc..4d2845a 100644 --- a/src/omit.ts +++ b/src/omit.ts @@ -1,5 +1,5 @@ -import { record } from './record' -import { Runtype, RuntypeUsageError } from './runtype' +import { internalRecord } from './record' +import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' /** * Build a new record runtype that omits some keys from the original. @@ -21,6 +21,8 @@ export function omit( delete newRecordFields[k] }) - // TODO: keep 'sloppyness' - return record(newRecordFields) as Runtype + return internalRecord( + newRecordFields, + isNonStrictRuntype(original), + ) as Runtype } diff --git a/src/partial.ts b/src/partial.ts index ad1ab60..a7ab8c2 100644 --- a/src/partial.ts +++ b/src/partial.ts @@ -1,6 +1,6 @@ import { optional } from './optional' -import { record } from './record' -import { Runtype, RuntypeUsageError } from './runtype' +import { internalRecord } from './record' +import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' /** * Build a new record runtype that marks all keys as optional. @@ -26,6 +26,8 @@ export function partial( } } - // TODO: keep 'sloppyness' - return record(newRecordFields) as Runtype + return internalRecord( + newRecordFields, + isNonStrictRuntype(original), + ) as Runtype } diff --git a/src/pick.ts b/src/pick.ts index 546c0f5..9584d04 100644 --- a/src/pick.ts +++ b/src/pick.ts @@ -1,5 +1,5 @@ -import { record } from './record' -import { Runtype, RuntypeUsageError } from './runtype' +import { internalRecord } from './record' +import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' /** * Build a new record runtype that contains some keys from the original @@ -20,6 +20,8 @@ export function pick( newRecordFields[k] = fields[k] }) - // TODO: keep 'sloppyness' - return record(newRecordFields) as Runtype + return internalRecord( + newRecordFields, + isNonStrictRuntype(original), + ) as Runtype } diff --git a/src/record.ts b/src/record.ts index 9c3363d..48caf7d 100644 --- a/src/record.ts +++ b/src/record.ts @@ -10,6 +10,7 @@ import { OptionalRuntype, Collapse, Unpack, + isNonStrictRuntypeSymbol, } from './runtype' import { debugValue } from './runtypeError' @@ -27,12 +28,14 @@ function isPureTypemap(typemap: object) { return true } -function internalRecord( - typemap: { [key: string]: Runtype | OptionalRuntype }, - sloppy: boolean, +export function internalRecord( + typemap: { + [key: string]: Runtype | OptionalRuntype + }, + isNonStrict = false, ): Runtype { - // a sloppy record may ignore keys and so cannot be pure - const isPure = !sloppy && isPureTypemap(typemap) + // a nonStrict record may ignore keys and so cannot be pure + const isPure = !isNonStrict && isPureTypemap(typemap) // cache typemap in arrays for a faster loop const typemapKeys = [...Object.keys(typemap)] @@ -74,7 +77,7 @@ function internalRecord( } } - if (!sloppy) { + if (!isNonStrict) { const unknownKeys: string[] = [] for (const k in o) { @@ -106,6 +109,11 @@ function internalRecord( // eslint-disable-next-line no-extra-semi ;(rt as any).fields = fields + if (isNonStrict) { + // eslint-disable-next-line no-extra-semi + ;(rt as any).isNonStrict = isNonStrictRuntypeSymbol + } + return rt } @@ -144,7 +152,7 @@ export function record< { [K in OptionalKeys]?: Unpack } > > { - return internalRecord(typemap as any, false) + return internalRecord(typemap as any) } /** diff --git a/src/runtype.ts b/src/runtype.ts index a3de6c2..55bdf19 100644 --- a/src/runtype.ts +++ b/src/runtype.ts @@ -183,3 +183,9 @@ export function isFail(v: unknown): v is Fail { return (v as any)[failSymbol] } + +export function isNonStrictRuntype(fn: Runtype): boolean { + return !!(fn as any).isNonStrict +} + +export const isNonStrictRuntypeSymbol = Symbol('isNonStrict') diff --git a/test/nonStrict.test.ts b/test/nonStrict.test.ts new file mode 100644 index 0000000..b767c03 --- /dev/null +++ b/test/nonStrict.test.ts @@ -0,0 +1,155 @@ +import { + expectAcceptValuesImpure, + expectRejectValues, + objectAttributes, + st, +} from './helpers' + +describe('nonStrict', () => { + it('accepts records', () => { + const runtype = st.nonStrict( + st.record({ + a: st.integer(), + b: st.string(), + }), + ) + + // a nonStrict runtype is always impure + expectAcceptValuesImpure( + runtype, + [ + [ + { a: 0, b: 'foo', c: true }, // ignores additional keys + { a: 0, b: 'foo' }, + ], + ], + true, + ) + }) + + it('accepts nonStrict records', () => { + const runtype = st.nonStrict( + st.nonStrict( + st.record({ + a: st.integer(), + b: st.string(), + }), + ), + ) + + expectAcceptValuesImpure( + runtype, + [ + [ + { a: 0, b: 'foo', c: true }, + { a: 0, b: 'foo' }, + ], + ], + true, + ) + }) + + it('accepts records with optional values', () => { + const runtype = st.nonStrict( + st.record({ + a: st.integer(), + b: st.optional(st.string()), + }), + ) + + expectAcceptValuesImpure( + runtype, + [ + [ + { a: 0, b: 'foo', c: true }, + { a: 0, b: 'foo' }, + ], + [ + { a: 0, b: undefined, c: true }, // ignores additional keys + { a: 0, b: undefined }, + ], + ], + true, + ) + }) + + it('accepts nested records and does not apply recursively', () => { + const runtype = st.nonStrict( + st.record({ + a: st.record({ + b: st.record({ + c: st.string(), + }), + }), + }), + ) + + expectAcceptValuesImpure( + runtype, + [[{ a: { b: { c: 'foo' } }, d: 0 }, { a: { b: { c: 'foo' } } }]], + true, + ) + expectRejectValues(runtype, [ + { a: { b: { c: 'foo' }, d: 0 } }, + { a: { b: { c: 'foo', d: 0 } } }, + ]) + }) + + it('rejects runtypes that are not records', () => { + const nonRecordRuntypes: any[] = [ + st.array(st.string()), + st.dictionary(st.union(st.literal('a'), st.literal('b')), st.boolean()), + st.enum({ A: 'a', B: 'b' }), + st.literal(0), + st.null(), + st.string(), + st.tuple(st.string(), st.number()), + st.undefined(), + st.union(st.string(), st.number()), + + // An intersection runtype not between two record runtypes. This is because an intersection between two + // record runtypes would result in a record runtype, which is valid to use with nonStrict. + st.intersection( + st.union( + st.record({ tier: st.literal('One'), price: st.number() }), + st.record({ + tier: st.literal('Two'), + price: st.number(), + access: st.boolean(), + }), + ), + st.record({ c: st.boolean() }), + ), + ] + + nonRecordRuntypes.forEach((runtype) => { + expect(() => { + st.nonStrict(runtype) + }).toThrow('expected a record runtype') + }) + }) + + // same as sloppyRecord + it('ignores object attributes', () => { + const runType = st.nonStrict( + st.record({ + x: st.number(), + }), + ) + + objectAttributes + // JSON.parse bc the __proto__ attr cannot be assigned in js + .map((a) => ({ a, o: JSON.parse(`{"x": 1, "${a}": {"y":2}}`) })) + .forEach(({ a, o }) => { + const x = runType(o) + const y = runType(Object.assign({}, o)) + expect(x).not.toBe(o) + expect(x).toEqual({ x: 1 }) + expect(y).toEqual({ x: 1 }) + expect((x as any).y).toBeUndefined() + expect((y as any).y).toBeUndefined() + expect(Object.prototype.hasOwnProperty.call(x, a)).toBeFalsy() + expect(Object.prototype.hasOwnProperty.call(y, a)).toBeFalsy() + }) + }) +}) diff --git a/test/partial.test.ts b/test/partial.test.ts index 534660c..2e6488a 100644 --- a/test/partial.test.ts +++ b/test/partial.test.ts @@ -1,4 +1,9 @@ -import { expectAcceptValuesPure, expectRejectValues, st } from './helpers' +import { + expectAcceptValuesImpure, + expectAcceptValuesPure, + expectRejectValues, + st, +} from './helpers' describe('partial', () => { const record = st.record({ @@ -50,4 +55,14 @@ describe('partial', () => { expectRejectValues(partialRt, rejectedValues) }) + + it('should preserve nonStrict', () => { + const nonStrictRecordRuntype = st.nonStrict(st.record({ a: st.string() })) + const partialRuntype = st.partial(nonStrictRecordRuntype) + expectAcceptValuesImpure( + partialRuntype, + [[{ a: 'a', b: 'b' }, { a: 'a' }]], + true, + ) + }) }) diff --git a/test/pick.omit.test.ts b/test/pick.omit.test.ts index d86c325..0dffbf2 100644 --- a/test/pick.omit.test.ts +++ b/test/pick.omit.test.ts @@ -1,4 +1,9 @@ -import { expectAcceptValuesPure, expectRejectValues, st } from './helpers' +import { + expectAcceptValuesImpure, + expectAcceptValuesPure, + expectRejectValues, + st, +} from './helpers' describe('pick & omit', () => { const record = st.record({ @@ -37,6 +42,31 @@ describe('pick & omit', () => { expectRejectValues(pickedRt, rejectedValues) expectRejectValues(omittedRt, rejectedValues) }) + + it('should preserve nonStrict', () => { + const nonStrictRecordRuntype = st.nonStrict( + st.record({ a: st.string(), b: st.string(), c: st.string() }), + ) + + const pickedRuntype = st.pick(nonStrictRecordRuntype, 'a', 'b') + const omittedRuntype = st.omit(nonStrictRecordRuntype, 'a', 'b') + + expectAcceptValuesImpure( + pickedRuntype, + [ + [ + { a: 'a', b: 'b', c: 'c', d: 'd' }, + { a: 'a', b: 'b' }, + ], + ], + true, + ) + expectAcceptValuesImpure( + omittedRuntype, + [[{ a: 'a', b: 'b', c: 'c', d: 'd' }, { c: 'c' }]], + true, + ) + }) }) describe('error messages', () => {