Skip to content

Commit 1151847

Browse files
authored
feat: add union schema implementation with support for optional, default, and transform modifiers (#5)
1 parent 1ba3e3e commit 1151847

4 files changed

Lines changed: 249 additions & 2 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { number, parse, safeParse, string, union } from '../../index'
4+
5+
describe('union schema', () => {
6+
it('accepts values from any branch', async () => {
7+
const schema = union([number(), string()])
8+
9+
await expect(parse(schema, 42)).resolves.toBe(42)
10+
await expect(parse(schema, 'spur')).resolves.toBe('spur')
11+
})
12+
13+
it('rejects values outside all branches', async () => {
14+
const schema = union([number(), string()])
15+
16+
const report = await safeParse(schema, true)
17+
18+
expect(report.passed).toBe(false)
19+
expect('value' in report).toBe(false)
20+
})
21+
22+
it('supports optional modifier', async () => {
23+
const schema = union([number(), string()]).optional()
24+
25+
const definedReport = await safeParse(schema, 'ok')
26+
expect(definedReport.passed).toBe(true)
27+
expect(definedReport.value).toBe('ok')
28+
29+
const optionalReport = await safeParse(schema, undefined)
30+
expect(optionalReport.passed).toBe(true)
31+
expect(optionalReport.value).toBeUndefined()
32+
33+
const nullReport = await safeParse(schema, null)
34+
expect(nullReport.passed).toBe(false)
35+
})
36+
37+
it('supports default modifier', async () => {
38+
const schema = union([number(), string()]).default('fallback')
39+
40+
await expect(parse(schema, undefined)).resolves.toBe('fallback')
41+
await expect(parse(schema, null)).resolves.toBe('fallback')
42+
await expect(parse(schema, 7)).resolves.toBe(7)
43+
})
44+
45+
it('supports transform', async () => {
46+
const schema = union([number(), string()]).transform((value) => {
47+
return typeof value === 'number' ? value * 2 : value.toUpperCase()
48+
})
49+
50+
await expect(parse(schema, 5)).resolves.toBe(10)
51+
await expect(parse(schema, 'spur')).resolves.toBe('SPUR')
52+
})
53+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { SchemaReport } from '../types/report'
2+
import type { BranchCheckable, BranchCheckableImport, BuildableSchema, EvaluableSchema } from '../types/schema'
3+
4+
export async function buildEvaluableUnionSchema<TSchemas extends readonly BuildableSchema<any, any, any>[], TOutput>(
5+
schemas: TSchemas,
6+
optionalityBranchCheckableImport: BranchCheckableImport<any> | undefined,
7+
): Promise<EvaluableSchema<TOutput>> {
8+
if (schemas.length === 0) {
9+
throw new Error('Union requires at least one schema')
10+
}
11+
12+
const optionalityPromise = optionalityBranchCheckableImport?.() ?? undefined
13+
const evaluableSchemasPromise = Promise.all(schemas.map(schema => schema['~build']()))
14+
15+
const [optionalityBranchCheckable, evaluableSchemas] = await Promise.all([optionalityPromise, evaluableSchemasPromise])
16+
17+
return buildUnionSchema<TOutput>(evaluableSchemas, optionalityBranchCheckable)
18+
}
19+
20+
export async function buildEvaluableUnionSchemaWithTransform<
21+
TSchemas extends readonly BuildableSchema<any, any, any>[],
22+
TOutput,
23+
TTransformOutput,
24+
>(
25+
schemas: TSchemas,
26+
optionalityBranchCheckableImport: BranchCheckableImport<any> | undefined,
27+
transformFn: (input: TOutput) => TTransformOutput,
28+
): Promise<EvaluableSchema<TTransformOutput>> {
29+
const baseSchema = await buildEvaluableUnionSchema<TSchemas, TOutput>(schemas, optionalityBranchCheckableImport)
30+
31+
return {
32+
safeParse: (input: unknown) => {
33+
const report = baseSchema.safeParse(input) as SchemaReport<TOutput>
34+
if (report.passed) {
35+
return {
36+
...report,
37+
value: transformFn(report.value),
38+
} as SchemaReport<TTransformOutput>
39+
}
40+
41+
return report as SchemaReport<TTransformOutput>
42+
},
43+
parse: (input: unknown) => {
44+
const value = baseSchema.parse(input)
45+
return transformFn(value)
46+
},
47+
}
48+
}
49+
50+
export function buildUnionSchema<TOutput>(
51+
evaluableSchemas: readonly EvaluableSchema<any>[],
52+
optionalityBranchCheckable: BranchCheckable<any> | undefined,
53+
): EvaluableSchema<TOutput> {
54+
if (evaluableSchemas.length === 0 && !optionalityBranchCheckable) {
55+
throw new Error('Union requires at least one schema')
56+
}
57+
58+
function safeParse(input: unknown): SchemaReport<TOutput> {
59+
const reports: SchemaReport<TOutput>[] = []
60+
61+
for (const schema of evaluableSchemas) {
62+
const report = schema.safeParse(input) as SchemaReport<TOutput>
63+
if (!report.passed) {
64+
delete (report as any).value
65+
}
66+
reports.push(report)
67+
}
68+
69+
if (optionalityBranchCheckable) {
70+
const optionalityReport = optionalityBranchCheckable['~c'](input) as SchemaReport<TOutput>
71+
if (!optionalityReport.passed) {
72+
delete optionalityReport.value
73+
}
74+
reports.push(optionalityReport)
75+
}
76+
77+
if (reports.length === 0) {
78+
throw new Error('Union requires at least one schema')
79+
}
80+
81+
let bestReport: SchemaReport<TOutput> | undefined
82+
for (const report of reports) {
83+
bestReport = bestReport ? selectBetterReport(bestReport, report) : report
84+
}
85+
86+
if (!bestReport) {
87+
throw new Error('Union requires at least one schema')
88+
}
89+
90+
const remainingReports = reports.filter(report => report !== bestReport)
91+
if (remainingReports.length > 0) {
92+
const existingUnionReports = bestReport.unionReports ?? []
93+
bestReport.unionReports = existingUnionReports.length > 0
94+
? [...existingUnionReports, ...remainingReports]
95+
: remainingReports
96+
}
97+
98+
return bestReport
99+
}
100+
101+
return {
102+
safeParse,
103+
parse: (input: unknown) => {
104+
const report = safeParse(input)
105+
if (report.passed) {
106+
return report.value as TOutput
107+
}
108+
throw new Error('Input did not match any union branch')
109+
},
110+
}
111+
}
112+
113+
function selectBetterReport<TOutput>(
114+
currentBest: SchemaReport<TOutput>,
115+
candidate: SchemaReport<TOutput>,
116+
): SchemaReport<TOutput> {
117+
if (candidate.passed) {
118+
if (!currentBest.passed) {
119+
return candidate
120+
}
121+
return candidate.score > currentBest.score ? candidate : currentBest
122+
}
123+
124+
if (currentBest.passed) {
125+
return currentBest
126+
}
127+
128+
return candidate.score > currentBest.score ? candidate : currentBest
129+
}

packages/spur/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './leitplanken/number'
22
export * from './leitplanken/string'
33
export * from './leitplanken/undefined'
4+
export * from './leitplanken/union'
45
export * from './parse'

packages/spur/src/leitplanken/union/index.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CommonOptions, DefaultCommonOptions, MakeDefaulted, MakeExactOptional, MakeNullable, MakeNullish, MakeOptional, MakeRequired, MakeUndefinable } from '../../options/options'
2-
import type { BuildableSchema, DefaultInput } from '../../types/schema'
2+
import type { BranchCheckableImport, BuildableSchema, DefaultInput } from '../../types/schema'
33
import type { InferInput, InferOutput } from '../../types/utils'
44

55
// TODO: primitives like string and number could be extended to have like "1" | "2" | (string & {}) as output to keep typehints
@@ -24,5 +24,69 @@ export interface UnionSchema<TSchemas extends readonly BuildableSchema<unknown,
2424
export function union<TSchemas extends readonly BuildableSchema<unknown, unknown, CommonOptions>[]>(_schemas: TSchemas): UnionSchema<TSchemas, InferUnionOutput<TSchemas>, InferUnionInput<TSchemas>, DefaultCommonOptions>
2525
export function union<TSchemas extends readonly BuildableSchema<unknown, unknown, CommonOptions>[], TOutput, TInput, TCommonOptions extends CommonOptions>(_schemas: TSchemas): UnionSchema<TSchemas, TOutput, TInput, TCommonOptions>
2626
export function union<TSchemas extends readonly BuildableSchema<unknown, unknown, CommonOptions>[], TOutput = InferUnionOutput<TSchemas>, TInput = InferUnionInput<TSchemas>, TCommonOptions extends CommonOptions = DefaultCommonOptions>(_schemas: TSchemas): UnionSchema<TSchemas, TOutput, TInput, TCommonOptions> {
27-
return 1 as any
27+
if (_schemas.length === 0) {
28+
throw new Error('Union requires at least one schema')
29+
}
30+
31+
const schemas = _schemas
32+
33+
let optionalityBranchCheckableImport: BranchCheckableImport<any> | undefined
34+
35+
const u: UnionSchema<TSchemas, TOutput, TInput, TCommonOptions> = {
36+
'default': (value) => {
37+
optionalityBranchCheckableImport = () => import('../_shared/optionality/defaulted').then(m => m.default(value))
38+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas>, TInput | undefined | null, MakeDefaulted<TCommonOptions>>
39+
},
40+
41+
'optional': () => {
42+
optionalityBranchCheckableImport = () => import('../_shared/optionality/optional').then(m => m.default)
43+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas> | undefined, InferUnionInput<TSchemas> | undefined, MakeOptional<TCommonOptions>>
44+
},
45+
46+
'exactOptional': () => {
47+
optionalityBranchCheckableImport = () => import('../_shared/optionality/exactOptional').then(m => m.default)
48+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas> | undefined, InferUnionInput<TSchemas> | undefined, MakeExactOptional<TCommonOptions>>
49+
},
50+
51+
'undefinable': () => {
52+
optionalityBranchCheckableImport = () => import('../_shared/optionality/undefinable').then(m => m.default)
53+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas> | undefined, InferUnionInput<TSchemas> | undefined, MakeUndefinable<TCommonOptions>>
54+
},
55+
56+
'required': () => {
57+
optionalityBranchCheckableImport = undefined
58+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas>, InferUnionInput<TSchemas>, MakeRequired<TCommonOptions>>
59+
},
60+
61+
'nullable': () => {
62+
optionalityBranchCheckableImport = () => import('../_shared/optionality/nullable').then(m => m.default)
63+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas> | null, InferUnionInput<TSchemas> | null, MakeNullable<TCommonOptions>>
64+
},
65+
66+
'nullish': () => {
67+
optionalityBranchCheckableImport = () => import('../_shared/optionality/nullish').then(m => m.default)
68+
return u as any as UnionSchema<TSchemas, InferUnionOutput<TSchemas> | undefined | null, InferUnionInput<TSchemas> | undefined | null, MakeNullish<TCommonOptions>>
69+
},
70+
71+
'~build': async () => {
72+
return import('../../build/unionBuild').then(m => m.buildEvaluableUnionSchema<TSchemas, TOutput>(
73+
schemas,
74+
optionalityBranchCheckableImport,
75+
))
76+
},
77+
78+
'transform': (fn) => {
79+
return {
80+
'~build': async () => {
81+
return import('../../build/unionBuild').then(m => m.buildEvaluableUnionSchemaWithTransform(
82+
schemas,
83+
optionalityBranchCheckableImport,
84+
fn as any,
85+
))
86+
},
87+
}
88+
},
89+
}
90+
91+
return u
2892
}

0 commit comments

Comments
 (0)