How CommonGrants Supports Custom Fields: Typed Schema Extensions for Grant Opportunities
CommonGrants supports custom fields through the withCustomFields extension, which merges a base Zod schema with a specification object to create strongly-typed customFields validation with full TypeScript autocomplete.
The CommonGrants SDK—part of the HHS Simpler Grants Protocol—enables developers to extend standard grant opportunity schemas with domain-specific data without sacrificing type safety. By implementing CommonGrants custom fields through Zod-based schema composition, the system validates extended data structures at runtime while providing compile-time type inference for registered fields.
Architecture of the Custom Fields System
The custom fields implementation spans multiple layers of the SDK, from type definitions to client integrations.
Core Files:
src/extensions/types.ts— Defines theCustomFieldSpecinterface that describes a field’s Zod type, description, and optional UI hints.src/extensions/with-custom-fields.ts— Implements thewithCustomFields()function that injects typedcustomFieldsinto base schemas.src/extensions/get-custom-field-value.ts— Provides thegetCustomFieldValue()helper for safe runtime extraction of field values.src/client/opportunities.ts— Integrates custom field schemas into theOpportunitiesClientfor type-safe API consumption.website/src/lib/custom-fields/loader.ts— Loads the static registry of custom-field definitions fromcontent/custom-fields/index.jsonfor documentation purposes.
Execution Flow:
- Developers define a
Record<string, CustomFieldSpec>mapping field keys to their type specifications. - The
withCustomFields(baseSchema, specs)function returns anExtendedSchemathat includes thecustomFieldsproperty. - Calling
ExtendedSchema.parse(json)validates both base fields and registered custom fields. - TypeScript infers
customFields.<key>.valuetypes, whilegetCustomFieldValue()enables dynamic access with null-safety.
At runtime, the SDK validates unregistered custom fields by passing them through unchanged, but only fields declared in the specification receive strong typing and validation.
The withCustomFields Extension
The withCustomFields() function in src/extensions/with-custom-fields.ts serves as the primary mechanism for extending schemas. It accepts two parameters: a base Zod schema (such as OpportunityBaseSchema) and a specification object containing CustomFieldSpec definitions.
The function constructs a new Zod schema that adds a customFields property to the base structure. Each key in the specification becomes a typed field within customFields, complete with the Zod validators defined in the spec. Because the extension builds on Zod, any custom validation—including regex patterns, enums, or complex object shapes—works without additional configuration.
Defining Custom Field Specifications
The CustomFieldSpec interface in src/extensions/types.ts describes the metadata and validation rules for each custom field. Specifications include the Zod type definition, an optional description for documentation, and flags indicating whether the field is required.
When you pass these specifications to withCustomFields(), the SDK generates TypeScript types that reflect the exact structure of your custom fields, enabling autocomplete and compile-time error detection when accessing customFields.totalBudget.value or similar paths.
Runtime Validation and Type Extraction
For scenarios requiring dynamic field access, the getCustomFieldValue() helper in src/extensions/get-custom-field-value.ts extracts typed values from parsed objects while gracefully handling null or undefined cases. This function ensures that even when field keys are determined at runtime, you maintain type safety through TypeScript’s type guards.
Code Examples
Extending a Schema with Custom Fields
This example from examples/custom-fields.ts demonstrates extending the base opportunity schema with budget and applicant type fields:
import { z } from "zod";
import {
withCustomFields,
getCustomFieldValue,
} from "../src/extensions";
/* Base Opportunity schema (provided by the SDK) */
const OpportunityBaseSchema = z.object({
id: z.string(),
title: z.string(),
// …other core fields
});
/* Custom field specifications */
const customFieldSpecs = {
// A required numeric field
totalBudget: {
type: z.number().positive(),
description: "Total budget allocated to the grant",
},
// An optional enum field
applicantType: {
type: z.enum(["Individual", "Organization", "Other"]),
description: "Who is applying for the grant",
required: false,
},
} as const;
/* Create an extended schema */
const OpportunitySchema = withCustomFields(OpportunityBaseSchema, customFieldSpecs);
/* Example API payload */
const apiResponse = {
id: "op-123",
title: "Community Health Grant",
customFields: {
totalBudget: { value: 50000 },
applicantType: { value: "Organization" },
},
};
/* Parse & type‑safe access */
const parsed = OpportunitySchema.parse(apiResponse);
console.log(parsed.customFields.totalBudget.value); // 50000 (number)
console.log(parsed.customFields.applicantType.value); // "Organization"
Accessing Fields Dynamically
When you need to access fields by variable name, use the runtime helper from src/extensions/get-custom-field-value.ts:
import { getCustomFieldValue } from "../src/extensions";
/* Assume `parsed` from the previous example */
const budget = getCustomFieldValue(parsed.customFields, "totalBudget");
const applicant = getCustomFieldValue(parsed.customFields, "applicantType");
console.log(budget); // 50000
console.log(applicant); // "Organization"
Integrating with the Opportunities Client
The OpportunitiesClient in src/client/opportunities.ts accepts a custom schema option for type-safe API responses:
import { OpportunitiesClient } from "../src/client";
import { withCustomFields } from "../src/extensions";
const client = new OpportunitiesClient({ apiKey: "YOUR_API_KEY" });
const schema = withCustomFields(OpportunitiesClient.baseOpportunitySchema, {
federalAgency: { type: z.string() },
totalBudget: { type: z.number().positive() },
});
const opp = await client.getOpportunity("op-123", { schema });
console.log(opp.customFields.federalAgency.value);
Summary
- CommonGrants custom fields rely on the
withCustomFields()extension to merge Zod schemas with domain-specific specifications. - The
CustomFieldSpecinterface insrc/extensions/types.tsdefines field types, descriptions, and validation rules. - TypeScript generates full autocomplete and compile-time safety for registered custom fields while allowing unregistered fields to pass through validation.
- The
getCustomFieldValue()helper enables type-safe dynamic access to custom fields at runtime. - The
OpportunitiesClientaccepts extended schemas to ensure API responses validate against your custom field definitions.
Frequently Asked Questions
What happens to unregistered custom fields at runtime?
Unregistered custom fields pass through validation unchanged, allowing flexibility for API responses that include fields not defined in your specification. However, only fields declared in the CustomFieldSpec receive strong TypeScript typing and Zod validation according to the implementation in src/extensions/with-custom-fields.ts.
Can I use complex validation rules like regex or enums?
Yes. Because the system builds on Zod, any validation available in Zod—including regex patterns, enum constraints, or custom refinements—works within your CustomFieldSpec definitions. Pass these validators directly to the type property in your specification object.
How do I access custom fields dynamically when the key is determined at runtime?
Use the getCustomFieldValue() function exported from src/extensions/get-custom-field-value.ts. This helper extracts values from the customFields object while preserving type information and handling null or undefined cases safely.
Where are the core custom field types defined in the SDK?
The primary type definitions reside in src/extensions/types.ts, which contains the CustomFieldSpec interface and internal type transforms. The schema extension logic lives in src/extensions/with-custom-fields.ts, while runtime utilities are split between src/extensions/get-custom-field-value.ts and the client integration in src/client/opportunities.ts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →