/** * 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 } from "../types"; import type { CustomFieldSpec, HasCustomFields } from "./types"; /** * 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` * 3. Wraps it in `z.ZodObject` to maintain Zod schema compatibility * * The result is that `z.infer>` 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; * // Opportunity.customFields?.legacyId?.value.id → typed as number ✅ * ``` */ export type WithCustomFieldsResult> = z.ZodObject & { customFields: z.ZodOptional>>; }>; /** * 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; * // opp.customFields?.legacyId?.value.id → typed as number * // opp.customFields?.category?.value → typed as string * ``` */ export declare function withCustomFields>(baseSchema: TSchema, specs: TSpecs): WithCustomFieldsResult; /** * 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`, 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 * * 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; 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["value"] extends z.ZodType ? z.infer : never; /** Falls back to DefaultFieldTypeMap based on fieldType. */ type DefaultValueType = T["fieldType"] extends keyof DefaultFieldTypeMap ? DefaultFieldTypeMap[T["fieldType"]] : unknown; /** Composes the two helpers: use explicit schema if available, else default. */ type InferValueType = T["value"] extends z.ZodType ? InferFromValueSchema : DefaultValueType; /** * 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: ; * 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 = { name: string; fieldType: T["fieldType"]; value: InferValueType; 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` 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 * ``` * * This allows: * - `fields.legacyId?.value.id` → typed as `number` ✅ * - `fields.category?.value` → typed as `string` ✅ * - `fields.unknownField?.value` → typed as `unknown` (passthrough) */ type TypedCustomFields> = { [K in keyof TSpecs]?: TypedCustomField; } & Record; export {}; //# sourceMappingURL=with-custom-fields.d.ts.map