diff --git a/src/api.ts b/src/api.ts index 6c00c52..e7e048e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -116,6 +116,72 @@ export function union(options: ParsersOf): UntaggedUnionParser { return new UntaggedUnionParser(options); } +type ObjectParserType

= P extends ObjectParser ? T : never; + +type IntersectTypes = T extends readonly [ + infer H, + ...infer R, +] + ? ObjectParserType & IntersectTypes + : unknown; + +/** + * Combine multiple object parsers into one by intersecting their shapes. + * + * - Keys that exist in only one parser are kept as-is. + * - If the same key exists in multiple parsers and both values are `EVP.object(...)`, + * they are recursively intersected. + * - Otherwise, duplicate keys are rejected. + * + * @example + * ```ts + * const base = EVP.object({ APP_NAME: EVP.string() }); + * const db = EVP.object({ DB: EVP.object({ HOST: EVP.string() }) }); + * const parser = EVP.intersection(base, db); + * ``` + */ +export function intersection[]>( + ...parsers: P +): ObjectParser> { + if (parsers.length === 0) { + return emptyObject() as ObjectParser>; + } + let acc: ObjectParser = parsers[0]; + for (let i = 1; i < parsers.length; i++) { + acc = intersectTwoObjectParsers(acc, parsers[i], []); + } + return acc as ObjectParser>; +} + +function intersectTwoObjectParsers( + left: ObjectParser, + right: ObjectParser, + path: string[], +): ObjectParser { + const merged: Record = { ...left.fields }; + for (const key of Object.keys(right.fields)) { + const existing = merged[key]; + const incoming = (right.fields as Record)[key]; + if (existing === undefined) { + merged[key] = incoming; + continue; + } + if (existing instanceof ObjectParser && incoming instanceof ObjectParser) { + merged[key] = intersectTwoObjectParsers( + existing, + incoming, + path.concat(key), + ); + continue; + } + const at = path.length === 0 ? key : `${path.join('.')}.${key}`; + throw new Error(`intersection() duplicate key: ${at}`); + } + const result = new ObjectParser(merged) as ObjectParser; + result._description = left._description ?? right._description; + return result; +} + function enum_(values: T): Enum { return new Enum(values); } diff --git a/src/index.test.ts b/src/index.test.ts index 9bbc49b..e2f1c14 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -507,4 +507,40 @@ describe('EVP', () => { expect(result.error.message).toEqual('Unused variables: APP_BAR'); } }); + + test('intersection merges object parsers', () => { + const a = EVP.object({ + A: EVP.string(), + }); + const b = EVP.object({ + B: EVP.number(), + }); + const parser = EVP.intersection(a, b); + const config = parser.parse({ A: 'hello', B: '42' }); + expect(config).toEqual({ A: 'hello', B: 42 }); + }); + + test('intersection recursively merges nested objects', () => { + const base = EVP.object({ + mysql: EVP.object({ + host: EVP.string().env('MYSQL_HOST'), + }), + }); + const extra = EVP.object({ + mysql: EVP.object({ + port: EVP.number().env('MYSQL_PORT'), + }), + }); + const parser = EVP.intersection(base, extra); + const config = parser.parse({ MYSQL_HOST: '127.0.0.1', MYSQL_PORT: '3306' }); + expect(config).toEqual({ mysql: { host: '127.0.0.1', port: 3306 } }); + }); + + test('intersection rejects duplicate non-object keys', () => { + const a = EVP.object({ A: EVP.string() }); + const b = EVP.object({ A: EVP.number() }); + expect(() => EVP.intersection(a, b)).toThrowError( + 'intersection() duplicate key: A', + ); + }); });