-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathwith-custom-fields.ts
More file actions
309 lines (288 loc) · 10.8 KB
/
Copy pathwith-custom-fields.ts
File metadata and controls
309 lines (288 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
/**
* Custom Fields Extension
*
* Provides the withCustomFields() function for extending CommonGrants schemas
* with typed custom fields.
*
* @module @common-grants/sdk/extensions
*/
import { z } from "zod";
import type { CustomField, CustomFieldType } from "../types";
import { CustomFieldSchema } from "../schemas";
import type { CustomFieldSpec, HasCustomFields } from "./types";
// ############################################################################
// Public type - WithCustomFieldsResult
// ############################################################################
/**
* The return type of `withCustomFields()` - a Zod schema with typed customFields.
*
* This type:
* 1. Takes the base schema's shape and removes the original `customFields` property
* 2. Adds a new `customFields` property typed using `TypedCustomFields<TSpecs>`
* 3. Wraps it in `z.ZodObject` to maintain Zod schema compatibility
*
* The result is that `z.infer<WithCustomFieldsResult<...>>` produces a type where:
* - All base schema properties remain unchanged
* - `customFields` is optional (via `ZodOptional`)
* - Registered custom fields have strongly-typed `value` properties
* - Unregistered fields pass through with base `CustomField` type
*
* @example
* ```typescript
* const Schema = withCustomFields(OpportunityBaseSchema, {
* legacyId: { fieldType: "object", value: ... }
* } as const);
*
* type Opportunity = z.infer<typeof Schema>;
* // Opportunity.customFields?.legacyId?.value.id → typed as number ✅
* ```
*/
export type WithCustomFieldsResult<
TSchema extends HasCustomFields,
TSpecs extends Record<string, CustomFieldSpec>,
> = z.ZodObject<
Omit<TSchema["shape"], "customFields"> & {
customFields: z.ZodOptional<z.ZodType<TypedCustomFields<TSpecs>>>;
}
>;
// ############################################################################
// Public function - withCustomFields()
// ############################################################################
/**
* Extends a schema with typed custom fields.
*
* This function takes a base schema (like OpportunityBaseSchema) and a Record
* of custom field specifications keyed by field name, returning a new schema
* where the customFields property is typed according to the specs. The record
* key is used as the default for each CustomField's `name`; spec.description
* is used as the default for CustomField.description when present.
*
* Unregistered custom fields will still pass through validation but won't have
* typed access.
*
* @param baseSchema - The base Zod object schema to extend
* @param specs - Record of custom field specifications (key = field name)
* @returns A new schema with typed customFields
*
* @example
* ```typescript
* const LegacyIdValueSchema = z.object({
* system: z.string(),
* id: z.number().int(),
* });
*
* const OpportunitySchema = withCustomFields(OpportunityBaseSchema, {
* legacyId: {
* fieldType: "object",
* value: LegacyIdValueSchema,
* description: "Maps to the opportunity_id in the legacy system",
* },
* category: {
* fieldType: "string",
* description: "Grant category",
* },
* } as const);
*
* type Opportunity = z.infer<typeof OpportunitySchema>;
* // opp.customFields?.legacyId?.value.id → typed as number
* // opp.customFields?.category?.value → typed as string
* ```
*/
export function withCustomFields<
TSchema extends HasCustomFields,
const TSpecs extends Record<string, CustomFieldSpec>,
>(baseSchema: TSchema, specs: TSpecs): WithCustomFieldsResult<TSchema, TSpecs> {
// Validate that the base schema has a customFields property
const schemaShape = baseSchema.shape;
if (!("customFields" in schemaShape)) {
throw new Error(
"Cannot register custom fields on a schema that doesn't support them. " +
"The base schema must include a 'customFields' property (e.g., customFields: z.record(z.unknown()).nullish())"
);
}
// Build typed schema for each spec; record key is the field name
const typedFieldSchemas: Record<string, z.ZodTypeAny> = {};
for (const [key, spec] of Object.entries(specs)) {
typedFieldSchemas[key] = CustomFieldSchema.extend({
fieldType: z.literal(spec.fieldType),
value: getValueSchema(spec),
name: z.string().default(spec.name ?? key),
description:
spec.description !== undefined
? z.string().nullish().default(spec.description)
: z.string().nullish(),
}).optional();
}
// Create new customFields schema with passthrough for unknown fields
// Use nullish() to match the base schema's customFields: z.record(...).nullish()
const customFieldsSchema = z.object(typedFieldSchemas).passthrough().nullish();
// Extend base schema with new customFields
const result = baseSchema.extend({
customFields: customFieldsSchema,
});
return result as unknown as WithCustomFieldsResult<TSchema, TSpecs>;
}
// ############################################################################
// Internal - Default value schemas
// ############################################################################
/**
* Default Zod schemas for each CustomFieldType.
* Used when a value schema is not provided in a CustomFieldSpec.
*/
const DEFAULT_VALUE_SCHEMAS: Record<CustomFieldType, z.ZodTypeAny> = {
string: z.string(),
number: z.number(),
integer: z.number().int(),
boolean: z.boolean(),
object: z.record(z.unknown()),
array: z.array(z.unknown()),
};
/**
* Gets the value schema for a custom field spec.
* Returns the provided value schema or a default based on fieldType.
*/
function getValueSchema(spec: CustomFieldSpec): z.ZodTypeAny {
return spec.value ?? DEFAULT_VALUE_SCHEMAS[spec.fieldType];
}
// ############################################################################
// Internal - Type inference utilities
// ############################################################################
/**
* WHY THESE UTILITIES EXIST:
*
* The `withCustomFields()` function builds Zod schemas dynamically at runtime by
* iterating over `Object.entries(specs)`. However, TypeScript's type system
* operates at compile time and cannot "unroll" runtime loops to infer types.
*
* When we do:
* const schemas = {};
* for (const [name, spec] of Object.entries(specs)) { schemas[name] = ... }
*
* TypeScript only sees `Record<string, z.ZodTypeAny>`, losing all specific
* key-value type information.
*
* These type utilities bridge that gap by operating at the TYPE level instead
* of the VALUE level. They use TypeScript's mapped types over the Record's
* keys at compile time, reconstructing what the inferred type should be.
*/
/**
* Maps each CustomFieldType value to its corresponding default TypeScript type.
*
* This is used when a CustomFieldSpec doesn't provide a `value` schema. Instead
* of using `z.unknown()`, we infer a more specific type based on the fieldType.
*
* Example:
* - fieldType: "string" → value type: string
* - fieldType: "number" → value type: number
* - fieldType: "object" → value type: Record<string, unknown>
*
* Note: This map must include all values from the CustomFieldType union.
* If a new field type is added to CustomFieldTypeEnum, this map must be updated.
*/
type DefaultFieldTypeMap = {
string: string;
number: number;
integer: number;
boolean: boolean;
object: Record<string, unknown>;
array: unknown[];
};
/**
* Infers the TypeScript type for a custom field's `value` property.
*
* This conditional type works in two steps:
* 1. If the spec provides a `value` schema, use `z.infer<>` to get its TypeScript type
* 2. Otherwise, look up the default type from `DefaultFieldTypeMap` based on `fieldType`
*
* @example
* ```typescript
* // With value schema:
* type T1 = InferValueType<{ fieldType: "object", value: z.object({ id: z.number() }) }>;
* // T1 = { id: number }
*
* // Without value schema (uses default):
* type T2 = InferValueType<{ fieldType: "string" }>;
* // T2 = string
* ```
*/
/** Infers the value type from a spec's explicit value schema, if one is provided. */
type InferFromValueSchema<T extends CustomFieldSpec> = T["value"] extends z.ZodTypeAny
? z.infer<T["value"]>
: never;
/** Falls back to DefaultFieldTypeMap based on fieldType. */
type DefaultValueType<T extends CustomFieldSpec> = T["fieldType"] extends keyof DefaultFieldTypeMap
? DefaultFieldTypeMap[T["fieldType"]]
: unknown;
/** Composes the two helpers: use explicit schema if available, else default. */
type InferValueType<T extends CustomFieldSpec> = T["value"] extends z.ZodTypeAny
? InferFromValueSchema<T>
: DefaultValueType<T>;
/**
* Builds the complete TypeScript type for a single custom field object.
*
* This represents what a registered custom field looks like at runtime:
* {
* name: string;
* fieldType: "string" | "number" | ... (literal type from spec);
* value: <inferred from value schema or DefaultFieldTypeMap>;
* schema?: string | null;
* description?: string | null;
* }
*
* @example
* ```typescript
* type Field = TypedCustomField<{
* fieldType: "object",
* value: z.object({ system: z.string(), id: z.number() })
* }>;
* // Field = {
* // name: string;
* // fieldType: "object";
* // value: { system: string; id: number };
* // schema?: string | null;
* // description?: string | null;
* // }
* ```
*/
type TypedCustomField<T extends CustomFieldSpec> = {
name: string;
fieldType: T["fieldType"];
value: InferValueType<T>;
schema?: string | null;
description?: string | null;
};
/**
* Builds the complete `customFields` object type from a Record of specs.
*
* This is the core type transformation that makes `withCustomFields()` work.
* It does two things:
*
* 1. **Mapped type iteration**: `[K in keyof TSpecs]` iterates over each key
* in the specs Record at the TYPE level, creating a typed property for it
* using the spec value type at TSpecs[K].
*
* 2. **Passthrough for unknown fields**: `& Record<string, CustomField>` ensures
* that unregistered custom fields (not in the specs Record) can still pass
* through validation, but they'll be typed as the base `CustomField` type
* (with `value: unknown`).
*
* @example
* ```typescript
* type Fields = TypedCustomFields<{
* legacyId: { fieldType: "object", value: ... },
* category: { fieldType: "string" }
* }>;
* // Fields = {
* // legacyId?: { fieldType: "object", value: { system: string; id: number }, ... };
* // category?: { fieldType: "string", value: string, ... };
* // } & Record<string, CustomField>
* ```
*
* This allows:
* - `fields.legacyId?.value.id` → typed as `number` ✅
* - `fields.category?.value` → typed as `string` ✅
* - `fields.unknownField?.value` → typed as `unknown` (passthrough)
*/
type TypedCustomFields<TSpecs extends Record<string, CustomFieldSpec>> = {
[K in keyof TSpecs]?: TypedCustomField<TSpecs[K]>;
} & Record<string, CustomField>;