From 9f62d798eb1300ba9886fb286f09946374de560f Mon Sep 17 00:00:00 2001 From: Graham Fisher Date: Mon, 4 Dec 2023 02:39:38 -0500 Subject: [PATCH 1/2] getDomainKeys -> enumerate: track both enumerable and non-enumerable parts; support intersection; tests --- src/index.ts | 183 +++++++++++++++++++++++++++++-------- test/2.1.x/record.ts | 213 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 343 insertions(+), 53 deletions(-) diff --git a/src/index.ts b/src/index.ts index 570257de..a8994659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -324,44 +324,103 @@ export type TypeOfDictionary = { [K in TypeOf]: export type OutputOfDictionary = { [K in OutputOf]: OutputOf } function enumerableRecord( - keys: Array, + keys: Set, domain: D, codomain: C, - name = `{ [K in ${domain.name}]: ${codomain.name} }` + name = getRecordName(domain, codomain) ): RecordC { - const len = keys.length const props: Props = {} - for (let i = 0; i < len; i++) { - props[keys[i]] = codomain - } - const exactCodec = strict(props, name) + keys.forEach((key) => { + props[key] = codomain + }) + const strictCodec = strict(props, name) - return new DictionaryType( - name, - (u): u is { [K in TypeOf]: TypeOf } => exactCodec.is(u), - exactCodec.validate, - exactCodec.encode, - domain, - codomain - ) + return new DictionaryType(name, strictCodec.is, strictCodec.validate, strictCodec.encode, domain, codomain) } +type StringMembers = + | { literals: Set; nonEnumerable?: Mixed | NeverC } + | { literals?: Set; nonEnumerable: Mixed | NeverC } + /** * @internal */ -export function getDomainKeys(domain: D): Record | undefined { - if (isLiteralC(domain)) { - const literal = domain.value +export function enumerate(codec: T): StringMembers { + if (isLiteralC(codec)) { + const literal = codec.value if (string.is(literal)) { - return { [literal]: null } + return { literals: new Set([literal]) } + } + } else if (isKeyofC(codec)) { + return { literals: new Set(Object.keys(codec.keys)) } + } else if (isUnionC(codec)) { + const literals = new Set() + const nonEnumerableSubtypes: Array = [] + for (const type of codec.types) { + const { literals: subtypeLiterals, nonEnumerable: subtypeNonEnumerable } = enumerate(type) + subtypeLiterals?.forEach((key) => literals.add(key)) + if (subtypeNonEnumerable && !(subtypeNonEnumerable instanceof NeverType)) { + nonEnumerableSubtypes.push(subtypeNonEnumerable) + } + } + const len = nonEnumerableSubtypes.length + if (len > 0) { + const nonEnumerable = + len > 1 ? union(nonEnumerableSubtypes as [Mixed, Mixed, ...Array]) : nonEnumerableSubtypes[0] + // allow broader non-enumerable type to subsume narrower literal types + literals.forEach((literal) => { + if (nonEnumerable.is(literal)) { + literals.delete(literal) + } + }) + return literals.size > 0 ? { literals, nonEnumerable } : { nonEnumerable } + } else { + return literals.size > 0 ? { literals } : { nonEnumerable: never } + } + } else if (isIntersectionC(codec)) { + let literals: undefined | Set = undefined + const nonEnumerableSupertypes: Array = [] + for (const type of codec.types) { + const { literals: supertypeLiterals, nonEnumerable: supertypeNonEnumerable } = enumerate(type) + if (supertypeLiterals) { + if (!literals) { + literals = supertypeLiterals + } else { + literals.forEach((key) => { + if (!supertypeLiterals.has(key)) { + literals?.delete(key) + } + }) + } + } + if (supertypeNonEnumerable) { + nonEnumerableSupertypes.push(supertypeNonEnumerable) + } + } + if (literals) { + if (literals.size === 0) { + return { nonEnumerable: never } + } + const nonEnumerableSupertypesDisjointFromLiterals = nonEnumerableSupertypes.filter((nonEnumerable) => { + let shouldKeep = true + literals?.forEach((key) => { + if (nonEnumerable.is(key)) { + shouldKeep = false + return + } + }) + return shouldKeep + }) + if (nonEnumerableSupertypesDisjointFromLiterals.length > 0) { + return { nonEnumerable: never } + } else { + return { literals } + } + } else { + return { nonEnumerable: codec } } - } else if (isKeyofC(domain)) { - return domain.keys - } else if (isUnionC(domain)) { - const keys = domain.types.map((type) => getDomainKeys(type)) - return keys.some(undefinedType.is) ? undefined : Object.assign({}, ...keys) } - return undefined + return { nonEnumerable: codec } } function stripNonDomainKeys(o: any, domain: Mixed) { @@ -381,15 +440,16 @@ function stripNonDomainKeys(o: any, domain: Mixed) { } function nonEnumerableRecord( + nonEnumerable: Mixed, domain: D, codomain: C, - name = `{ [K in ${domain.name}]: ${codomain.name} }` + name = getRecordName(domain, codomain) ): RecordC { return new DictionaryType( name, (u): u is { [K in TypeOf]: TypeOf } => { if (UnknownRecord.is(u)) { - return Object.keys(u).every((k) => !domain.is(k) || codomain.is(u[k])) + return Object.keys(u).every((k) => !nonEnumerable.is(k) || codomain.is(u[k])) } return isAnyC(codomain) && Array.isArray(u) }, @@ -403,7 +463,7 @@ function nonEnumerableRecord( for (let i = 0; i < len; i++) { let k = keys[i] const ok = u[k] - const domainResult = domain.validate(k, appendContext(c, k, domain, k)) + const domainResult = nonEnumerable.validate(k, appendContext(c, k, nonEnumerable, k)) if (isLeft(domainResult)) { changed = true } else { @@ -427,15 +487,15 @@ function nonEnumerableRecord( } return failure(u, c) }, - domain.encode === identity && codomain.encode === identity - ? (a) => stripNonDomainKeys(a, domain) + nonEnumerable.encode === identity && codomain.encode === identity + ? (a) => stripNonDomainKeys(a, nonEnumerable) : (a) => { const s: { [key: string]: any } = {} - const keys = Object.keys(stripNonDomainKeys(a, domain)) + const keys = Object.keys(stripNonDomainKeys(a, nonEnumerable)) const len = keys.length for (let i = 0; i < len; i++) { const k = keys[i] - s[String(domain.encode(k))] = codomain.encode(a[k]) + s[String(nonEnumerable.encode(k))] = codomain.encode(a[k]) } return s as any }, @@ -448,6 +508,10 @@ function getUnionName]>(codecs: CS): s return '(' + codecs.map((type) => type.name).join(' | ') + ')' } +function getRecordName(domain: D, codomain: C): string { + return `{ [K in ${domain.name}]: ${codomain.name} }` +} + /** * @internal */ @@ -1512,11 +1576,39 @@ export interface RecordC * @category combinators * @since 1.7.1 */ -export function record(domain: D, codomain: C, name?: string): RecordC { - const keys = getDomainKeys(domain) - return keys - ? enumerableRecord(Object.keys(keys), domain, codomain, name) - : nonEnumerableRecord(domain, codomain, name) +export function record( + domain: D, + codomain: C, + name = getRecordName(domain, codomain) +): RecordC { + const { literals, nonEnumerable } = enumerate(domain) + if (literals && nonEnumerable && !(nonEnumerable instanceof NeverType)) { + const enumerablesObj: Record = {} + literals.forEach((k) => { + enumerablesObj[k] = null + }) + const intersectionCodec = intersection( + [ + nonEnumerableRecord(nonEnumerable, domain, codomain, getRecordName(nonEnumerable, codomain)), + enumerableRecord(literals, domain, codomain, getRecordName(keyof(enumerablesObj), codomain)) + ], + name + ) + return new DictionaryType( + name, + intersectionCodec.is, + intersectionCodec.validate, + intersectionCodec.encode, + domain, + codomain + ) + } else if (literals) { + return enumerableRecord(literals, domain, codomain, name) + } else if (nonEnumerable) { + return nonEnumerableRecord(nonEnumerable as any as Mixed, domain, codomain, name) + } else { + throw new Error(`unexpectedly found neither literal nor nonEnumerable keys in ${domain.name}`) + } } /** @@ -1697,6 +1789,7 @@ export function intersection( name?: string ): IntersectionC<[A, B, C]> export function intersection(codecs: [A, B], name?: string): IntersectionC<[A, B]> +export function intersection]>(codecs: CS, name?: string): IntersectionC export function intersection]>( codecs: CS, name = `(${codecs.map((type) => type.name).join(' & ')})` @@ -2428,3 +2521,19 @@ export function alias( export function alias(codec: Type): () => Type { return () => codec as any } + +// type R = Record<'foo' | 'bar', number> & Record + +// type R2 = Record<'foo' | 'bar' | string, number> +// // eslint-disable-next-line @typescript-eslint/no-unused-vars +// const r2: R2 = { foo: 1 } + +// type R4 = Record<'prefix:foo' | 'prefix:bar' | `prefix:${string}`, number> +// const r4: R4 = { 'prefix:foo': 1 } + +// const r3 = record(type({ foo: number }), number) +// type R3 = TypeOf + +// type intR1 = Record<`prefix:${string}` & string, number> + +// type intR1 = Record<'foo' & (string | number), number> diff --git a/test/2.1.x/record.ts b/test/2.1.x/record.ts index cf471e33..113e018e 100644 --- a/test/2.1.x/record.ts +++ b/test/2.1.x/record.ts @@ -12,6 +12,138 @@ import { } from './helpers' describe.concurrent('record', () => { + describe.concurrent('enumerateStringMembers', () => { + it('should handle literal', () => { + assert.deepStrictEqual(t.enumerate(t.literal('a')), { literals: new Set('a') }) + const literal1 = t.literal(1) + assert.deepStrictEqual(t.enumerate(literal1), { nonEnumerable: literal1 }) + }) + + it('should handle keyof', () => { + assert.deepStrictEqual(t.enumerate(t.keyof({ a: 1, b: 2 })), { literals: new Set(['a', 'b']) }) + }) + + it('should handle union', () => { + assert.deepStrictEqual(t.enumerate(t.union([t.literal('a'), t.literal('b')])), { + literals: new Set(['a', 'b']) + }) + assert.deepStrictEqual(t.enumerate(t.union([t.literal('a'), HyphenatedString])), { + literals: new Set(['a']), + nonEnumerable: HyphenatedString + }) + assert.deepStrictEqual(t.enumerate(t.union([t.literal('a'), t.string])), { nonEnumerable: t.string }) + assert.deepStrictEqual(t.enumerate(t.union([t.literal('a-a'), HyphenatedString])), { + nonEnumerable: HyphenatedString + }) + const union = t.union([HyphenatedString, t.string]) + const enumerated = t.enumerate(union) + assert.deepStrictEqual(enumerated.nonEnumerable?.name, union.name) + assert.deepStrictEqual(enumerated.literals, undefined) + }) + + it('should handle intersection', () => { + assert.deepStrictEqual(t.enumerate(t.intersection([t.literal('a'), t.literal('b')]) as any), { + nonEnumerable: t.never + }) + assert.deepStrictEqual(t.enumerate(t.intersection([t.literal('a'), t.string])), { + literals: new Set(['a']) + }) + assert.deepStrictEqual(t.enumerate(t.intersection([t.literal('a-a'), HyphenatedString])), { + literals: new Set(['a-a']) + }) + assert.deepStrictEqual(t.enumerate(t.intersection([t.literal('a'), t.number]) as any), { + nonEnumerable: t.never + }) + const res = t.enumerate(t.intersection([HyphenatedString, t.string])) + assert.deepStrictEqual(res.nonEnumerable?.name, t.intersection([HyphenatedString, t.string]).name) + assert.deepStrictEqual(res.literals, undefined) + const res2 = t.enumerate(t.intersection([t.number, t.string]) as any) + assert.deepStrictEqual(res2.nonEnumerable?.name, t.intersection([t.number, t.string]).name) + assert.deepStrictEqual(res2.literals, undefined) + }) + + it('should handle combinations of union and intersection', () => { + assert.deepStrictEqual(t.enumerate(t.intersection([t.union([t.literal('a'), t.literal('b')]), t.string])), { + literals: new Set(['a', 'b']) + }) + assert.deepStrictEqual( + t.enumerate(t.intersection([t.union([t.literal('a'), t.literal('b')]), t.string, HyphenatedString]) as any), + { + nonEnumerable: t.never + } + ) + const codec1 = t.intersection([t.string, HyphenatedString]) + assert.deepStrictEqual(t.enumerate(t.union([codec1, t.literal('a')])), { + literals: new Set(['a']), + nonEnumerable: codec1 + }) + const codec2 = t.intersection([t.string, HyphenatedString]) + assert.deepStrictEqual(t.enumerate(t.union([codec2, t.literal('a-a')])), { + nonEnumerable: codec2 + }) + assert.deepStrictEqual( + t.enumerate( + t.intersection([t.union([t.literal('a'), t.literal('b')]), t.union([t.literal('b'), t.literal('c')])]) + ), + { + literals: new Set(['b']) + } + ) + assert.deepStrictEqual( + t.enumerate( + t.intersection([t.union([t.literal('a'), t.literal('b')]), t.union([t.literal('c'), t.literal('d')])]) as any + ), + { + nonEnumerable: t.never + } + ) + assert.deepStrictEqual( + t.enumerate( + t.intersection([ + t.union([t.literal('a'), t.literal('a'), t.literal('b')]), + t.union([t.literal('c'), t.string]) + ]) + ), + { + literals: new Set(['a', 'b']) + } + ) + assert.deepStrictEqual( + t.enumerate( + t.union([ + t.intersection([t.literal('a'), HyphenatedString]) as any, + t.intersection([t.literal('c'), t.string]) + ]) + ), + { + literals: new Set(['c']) + } + ) + assert.deepStrictEqual( + t.enumerate( + t.union([t.intersection([t.literal('a-a'), HyphenatedString]), t.intersection([t.literal('c'), t.string])]) + ), + { + literals: new Set(['a-a', 'c']) + } + ) + assert.deepStrictEqual( + t.enumerate( + t.union([ + t.intersection([t.literal('a'), HyphenatedString]) as any, + t.intersection([t.literal('c'), t.literal('b')]) as any + ]) + ), + { + nonEnumerable: t.never + } + ) + assert.deepStrictEqual(t.enumerate(t.union([t.intersection([t.literal('a-a'), HyphenatedString]), t.string])), { + nonEnumerable: t.string + }) + }) + }) + describe.concurrent('nonEnumerableRecord', () => { describe.concurrent('name', () => { it('should assign a default name', () => { @@ -181,22 +313,6 @@ describe.concurrent('record', () => { }) describe.concurrent('enumerableRecord', () => { - describe.concurrent('getDomainKeys', () => { - it('should handle literal', () => { - assert.deepStrictEqual(t.getDomainKeys(t.literal('a')), { a: null }) - assert.deepStrictEqual(t.getDomainKeys(t.literal(1)), undefined) - }) - - it('should handle keyof', () => { - assert.deepStrictEqual(t.getDomainKeys(t.keyof({ a: 1, b: 2 })), { a: 1, b: 2 }) - }) - - it('should handle union', () => { - assert.deepStrictEqual(t.getDomainKeys(t.union([t.literal('a'), t.literal('b')])), { a: null, b: null }) - assert.deepStrictEqual(t.getDomainKeys(t.union([t.literal('a'), t.string])), undefined) - }) - }) - describe.concurrent('name', () => { it('should assign a default name', () => { const T = t.record(t.literal('a'), t.number) @@ -288,4 +404,69 @@ describe.concurrent('record', () => { }) }) }) + + describe.concurrent('partiallyEnumerableRecord', () => { + describe.concurrent('is', () => { + it('should return `true` on valid inputs', () => { + const T = t.record(t.union([t.literal('a'), HyphenatedString]), t.string) + assert.strictEqual(T.is({ a: 'a' }), true) + assert.strictEqual(T.is({ a: 'a', 'a-b': 'a' }), true) + assert.strictEqual(T.is({ a: 'a', 'a-b': 'a-b', b: 1 }), true) + }) + + it('should return `false` on invalid inputs', () => { + const T = t.record(t.union([t.literal('a'), HyphenatedString]), t.string) + assert.strictEqual(T.is({ 'a-b': 'a-b' }), false) + assert.strictEqual(T.is({ a: 'a', 'a-b': 1 }), false) + }) + }) + + describe.concurrent('decode', () => { + it('should support literals as domain type', () => { + const T = t.record(t.union([t.literal('a'), HyphenatedString]), t.string) + assertSuccess(T.decode({ a: 'a' }), { a: 'a' }) + assertSuccess(T.decode({ a: 'a', 'a-b': 'a' }), { a: 'a', 'a-b': 'a' }) + assertSuccess(T.decode({ a: 'a', 'a-b': 'a-b', b: 1 }), { a: 'a', 'a-b': 'a-b' }) + assertFailure(T, null, [ + 'Invalid value null supplied to : { [K in ("a" | `${string}-${string}`)]: string }/0: { [K in `${string}-${string}`]: string }', + 'Invalid value null supplied to : { [K in ("a" | `${string}-${string}`)]: string }/1: { [K in "a"]: string }' + ]) + assertFailure(T, {}, [ + 'Invalid value undefined supplied to : { [K in ("a" | `${string}-${string}`)]: string }/1: { [K in "a"]: string }/a: string' + ]) + assertFailure(T, { a: 1 }, [ + 'Invalid value 1 supplied to : { [K in ("a" | `${string}-${string}`)]: string }/1: { [K in "a"]: string }/a: string' + ]) + assertFailure(T, { 'a-b': 1 }, [ + 'Invalid value 1 supplied to : { [K in ("a" | `${string}-${string}`)]: string }/0: { [K in `${string}-${string}`]: string }/a-b: string', + 'Invalid value undefined supplied to : { [K in ("a" | `${string}-${string}`)]: string }/1: { [K in "a"]: string }/a: string' + ]) + + const T2 = t.record(t.union([t.literal('a'), HyphenatedString, t.string]), t.string) + assertFailure(T2, { c: 1 }, [ + 'Invalid value 1 supplied to : { [K in ("a" | `${string}-${string}` | string)]: string }/c: string' + ]) + + const T3 = t.record(t.intersection([t.literal('a'), t.string]), t.string) + assertFailure(T3, { c: 1 }, [ + 'Invalid value undefined supplied to : { [K in ("a" & string)]: string }/a: string' + ]) + }) + + it('should return the same reference while decoding isomorphic values if entirely enumerable or nonEnumerable', () => { + const T1 = t.record(t.union([t.literal('a'), t.string]), t.string) + const value1 = { a: 'a' } + assertStrictSuccess(T1.decode(value1), value1) + + const T2 = t.record(t.intersection([t.literal('a'), t.string]), t.string) + const value2 = { a: 'a' } + assertStrictSuccess(T2.decode(value2), value2) + }) + + it('should decode a prismatic value', () => { + const T = t.record(t.union([t.literal('a'), HyphenatedStringFromNonHyphenated]), t.string) + assertSuccess(T.decode({ a: 'a', bb: 'b-b' }), { a: 'a', 'b-b': 'b-b' }) + }) + }) + }) }) From e2f1956a6b5d00248531de99fdc66b3a541d7417 Mon Sep 17 00:00:00 2001 From: Graham Fisher Date: Mon, 4 Dec 2023 02:57:03 -0500 Subject: [PATCH 2/2] cleanup --- src/index.ts | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/src/index.ts b/src/index.ts index a8994659..0ff3fddc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -440,16 +440,16 @@ function stripNonDomainKeys(o: any, domain: Mixed) { } function nonEnumerableRecord( - nonEnumerable: Mixed, - domain: D, + nonEnumerableDomain: Mixed, + entireDomain: D, codomain: C, - name = getRecordName(domain, codomain) + name = getRecordName(entireDomain, codomain) ): RecordC { return new DictionaryType( name, (u): u is { [K in TypeOf]: TypeOf } => { if (UnknownRecord.is(u)) { - return Object.keys(u).every((k) => !nonEnumerable.is(k) || codomain.is(u[k])) + return Object.keys(u).every((k) => !nonEnumerableDomain.is(k) || codomain.is(u[k])) } return isAnyC(codomain) && Array.isArray(u) }, @@ -463,7 +463,7 @@ function nonEnumerableRecord( for (let i = 0; i < len; i++) { let k = keys[i] const ok = u[k] - const domainResult = nonEnumerable.validate(k, appendContext(c, k, nonEnumerable, k)) + const domainResult = nonEnumerableDomain.validate(k, appendContext(c, k, nonEnumerableDomain, k)) if (isLeft(domainResult)) { changed = true } else { @@ -487,19 +487,19 @@ function nonEnumerableRecord( } return failure(u, c) }, - nonEnumerable.encode === identity && codomain.encode === identity - ? (a) => stripNonDomainKeys(a, nonEnumerable) + nonEnumerableDomain.encode === identity && codomain.encode === identity + ? (a) => stripNonDomainKeys(a, nonEnumerableDomain) : (a) => { const s: { [key: string]: any } = {} - const keys = Object.keys(stripNonDomainKeys(a, nonEnumerable)) + const keys = Object.keys(stripNonDomainKeys(a, nonEnumerableDomain)) const len = keys.length for (let i = 0; i < len; i++) { const k = keys[i] - s[String(nonEnumerable.encode(k))] = codomain.encode(a[k]) + s[String(nonEnumerableDomain.encode(k))] = codomain.encode(a[k]) } return s as any }, - domain, + entireDomain, codomain ) } @@ -1607,7 +1607,7 @@ export function record( } else if (nonEnumerable) { return nonEnumerableRecord(nonEnumerable as any as Mixed, domain, codomain, name) } else { - throw new Error(`unexpectedly found neither literal nor nonEnumerable keys in ${domain.name}`) + throw new Error(`unexpectedly found neither enumerable nor non-enumerable keys in ${domain.name}`) } } @@ -1789,7 +1789,6 @@ export function intersection( name?: string ): IntersectionC<[A, B, C]> export function intersection(codecs: [A, B], name?: string): IntersectionC<[A, B]> -export function intersection]>(codecs: CS, name?: string): IntersectionC export function intersection]>( codecs: CS, name = `(${codecs.map((type) => type.name).join(' & ')})` @@ -2521,19 +2520,3 @@ export function alias( export function alias(codec: Type): () => Type { return () => codec as any } - -// type R = Record<'foo' | 'bar', number> & Record - -// type R2 = Record<'foo' | 'bar' | string, number> -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// const r2: R2 = { foo: 1 } - -// type R4 = Record<'prefix:foo' | 'prefix:bar' | `prefix:${string}`, number> -// const r4: R4 = { 'prefix:foo': 1 } - -// const r3 = record(type({ foo: number }), number) -// type R3 = TypeOf - -// type intR1 = Record<`prefix:${string}` & string, number> - -// type intR1 = Record<'foo' & (string | number), number>