Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,72 @@ export function union<T>(options: ParsersOf<void, T>): UntaggedUnionParser<T> {
return new UntaggedUnionParser(options);
}

type ObjectParserType<P> = P extends ObjectParser<infer T> ? T : never;

type IntersectTypes<T extends readonly unknown[]> = T extends readonly [
infer H,
...infer R,
]
? ObjectParserType<H> & IntersectTypes<R>
: 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<const P extends readonly ObjectParser<any>[]>(
...parsers: P
): ObjectParser<IntersectTypes<P>> {
if (parsers.length === 0) {
return emptyObject() as ObjectParser<IntersectTypes<P>>;
}
let acc: ObjectParser<any> = parsers[0];
for (let i = 1; i < parsers.length; i++) {
acc = intersectTwoObjectParsers(acc, parsers[i], []);
}
return acc as ObjectParser<IntersectTypes<P>>;
}

function intersectTwoObjectParsers<A, B>(
left: ObjectParser<A>,
right: ObjectParser<B>,
path: string[],
): ObjectParser<A & B> {
const merged: Record<string, any> = { ...left.fields };
for (const key of Object.keys(right.fields)) {
const existing = merged[key];
const incoming = (right.fields as Record<string, any>)[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<A & B>;
result._description = left._description ?? right._description;
return result;
}

function enum_<U extends string, T extends U[]>(values: T): Enum<U, T> {
return new Enum(values);
}
Expand Down
36 changes: 36 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
Loading