Custom fields overview
What are custom fields?
Section titled “What are custom fields?”The CommonGrants protocol defines a core set of fields that are widely applicable across federal, state, local, and private grant opportunities. However, many implementations need fields that are relevant to their specific context but aren’t universal enough to belong in the core protocol.
Custom fields solve this problem by providing a structured way to extend CommonGrants models with additional data. For the formal rules governing how custom fields work (e.g., which models support them, what implementations MUST and SHALL NOT do), see the custom fields section of the specification.
Each custom field includes:
| Property | Description |
|---|---|
name |
Name of the custom field |
fieldType |
The JSON schema type used when de-serializing the value |
schema |
(Optional) Link to the full JSON schema for the field |
value |
The field’s value |
description |
(Optional) Description of the custom field’s purpose |
Custom fields live in the customFields property of any CommonGrants model, allowing implementations to carry additional data while staying compatible with the base protocol.
About the custom field catalog
Section titled “About the custom field catalog”The custom field catalog is a shared repository of pre-defined custom fields that are not part of the core CommonGrants library but are reused across implementations.
The catalog has two primary goals:
-
Standardize semantically equivalent fields. Without a shared catalog, two different teams might define fields like “agency” with slightly different names, types, or structures. This makes interoperability harder and increases integration costs. The catalog gives implementers a single place to find and reuse existing field definitions before creating their own.
-
Create a path for promoting fields into the core protocol. As custom fields gain adoption across implementations, they become candidates for inclusion in the base protocol. The catalog provides visibility into which fields are widely used, creating a foundation for a “field promotion” pipeline where popular custom fields can move from the catalog into
@common-grants/core.
Migrating from agency to funders
Section titled “Migrating from agency to funders”The agency custom field is deprecated for opportunities. Protocol v0.5 added funders to OpportunityBase, so the organization offering an opportunity now has a home in the core protocol and no longer needs a custom field.
The field is not deprecated for competitions or forms, which have no funders equivalent. Only opportunity usage should migrate.
Mapping
Section titled “Mapping”funders is an OrgRefCollection: a required primary organization plus optional otherOrgs keyed by role. The four agency values map onto it as follows.
agency value |
Maps to |
|---|---|
name |
funders.primary.name |
code |
funders.primary.identifiers.otherIds["org:grants.gov:agency"].id |
parentName |
funders.otherOrgs.<role>.name |
parentCode |
funders.otherOrgs.<role>.identifiers.otherIds["org:grants.gov:agency"].id |
An opportunity that previously carried this custom field:
{ "customFields": { "agency": { "name": "agency", "fieldType": "object", "value": { "code": "HRSA", "name": "Health Resources and Services Administration", "parentCode": "HHS", "parentName": "Health and Human Services" } } }}carries the same information under funders. The parent role key below is illustrative: otherOrgs is keyed by an implementation-defined role, and which key names the parent agency is one of the plugin decisions listed above.
{ "funders": { "primary": { "id": "018f2e77-4b5c-7d2e-9f3a-bcdef1234567", "name": "Health Resources and Services Administration", "identifiers": { "otherIds": { "org:grants.gov:agency": { "registry": { "code": "org:grants.gov:agency" }, "id": "HRSA" } } } }, "otherOrgs": { "parent": { "id": "018f2e77-4b5c-7d2e-9f3a-bcdef1234568", "name": "Health and Human Services", "identifiers": { "otherIds": { "org:grants.gov:agency": { "registry": { "code": "org:grants.gov:agency" }, "id": "HHS" } } } } } }}Note that org:grants.gov:agency is not a registered code in the identifier registry catalog. It is carried under otherIds, which is where unregistered codes belong.
Requirements for a migration
Section titled “Requirements for a migration”A migration MUST NOT silently discard data. If a source record supplies any of code, name, parentCode, or parentName, every value it supplies has to survive the mapping. Dropping the parent agency because OrgRefCollection has only one primary is a loss, not a simplification: the parent belongs under otherOrgs.
OrgRef requires both id and name, and grants.gov agency records supply neither a UUID nor, in every case, a full name. Resolving that gap is what makes this a mapping decision rather than a rename.
Decisions owned by the plugin tickets
Section titled “Decisions owned by the plugin tickets”The exact grants.gov mapping lives in the TypeScript and Python plugin packages, not here. Those tickets decide:
- How to derive the required
OrgRef.iddeterministically from a source record, or whether to fail the record when no stable identifier exists. - Whether
agencykeeps being emitted alongsidefundersduring the transition, and which one wins when a record carries both. - Which
otherOrgsrole key names the parent agency, so consumers can rely on it. - Whether to register
org:grants.gov:agencyin the registry catalog, which would move it out ofotherIds.
Using custom fields
Section titled “Using custom fields”Both the TypeScript and Python SDKs provide helper functions for extending base models with typed custom fields.
Defining custom field specs
Section titled “Defining custom field specs”Before adding custom fields to a model, you define a spec for each field that describes its type and (optionally) a schema for validating its value.
import { z } from "zod";import { OpportunityBaseSchema } from "@common-grants/sdk/schemas";import { CustomFieldType } from "@common-grants/sdk/constants";import { withCustomFields } from "@common-grants/sdk/extensions";
// Define a value schema for structured typesconst CostSharingValueSchema = z.object({ isRequired: z.boolean().optional(), percentage: z.number().optional(), details: z.string().optional(),});
// Extend the base schema with typed custom fieldsconst OpportunitySchema = withCustomFields(OpportunityBaseSchema, { costSharing: { fieldType: CustomFieldType.object, value: CostSharingValueSchema, description: "Cost sharing or matching requirement for the opportunity", }, category: { fieldType: CustomFieldType.string, description: "Grant category", },} as const);from pydantic import BaseModel, Fieldfrom common_grants_sdk.schemas.pydantic import ( OpportunityBase, CustomFieldType,)from common_grants_sdk.extensions.specs import CustomFieldSpec
# Define a value type for structured typesclass CostSharingValue(BaseModel): # The wire format is camelCase, so snake_case attributes need an alias is_required: bool | None = Field(default=None, alias="isRequired") percentage: float | None = None details: str | None = None
# Extend the base model with typed custom fieldsOpportunity = OpportunityBase.with_custom_fields( custom_fields={ "costSharing": CustomFieldSpec( field_type=CustomFieldType.OBJECT, value=CostSharingValue, description="Cost sharing or matching requirement for the opportunity", ), "category": CustomFieldSpec( field_type=CustomFieldType.STRING, value=str, description="Grant category", ), }, model_name="Opportunity",)Parsing data with custom fields
Section titled “Parsing data with custom fields”Once you’ve defined your extended schema, use it to parse and validate incoming data. Registered custom fields get full type safety, while unregistered fields pass through validation with the base CustomField type.
import { getCustomFieldValue } from "@common-grants/sdk/extensions";
// Parse incoming dataconst opportunity = OpportunitySchema.parse(responseData);
// Access custom fields directly on the parsed objectconst costSharingField = opportunity.customFields?.costSharing;console.log(costSharingField?.value.isRequired); // typed as boolean | undefinedconsole.log(costSharingField?.value.percentage); // typed as number | undefined
// Or use getCustomFieldValue() to extract just the valueconst costSharing = getCustomFieldValue( opportunity.customFields, "costSharing", CostSharingValueSchema,);console.log(costSharing?.isRequired); // typed as boolean | undefined
const category = getCustomFieldValue( opportunity.customFields, "category", z.string(),);console.log(category); // typed as string | undefined# Parse incoming dataopp = Opportunity.model_validate(response_data)
# Access custom fields directly on the parsed objectcost_sharing_field = opp.custom_fields.cost_sharingprint(cost_sharing_field.value.is_required) # typed as bool | Noneprint(cost_sharing_field.value.percentage) # typed as float | None
# Or use get_custom_field_value() to extract just the valuecost_sharing = opp.get_custom_field_value("costSharing", CostSharingValue)print(cost_sharing.is_required) # typed as bool | None
category = opp.get_custom_field_value("category", str)print(category) # typed as str | NoneContributing custom fields
Section titled “Contributing custom fields”The custom field catalog is open to contributions. If you have a field that could be useful across multiple CommonGrants implementations, we encourage you to propose it.
When to contribute
Section titled “When to contribute”Consider contributing a custom field when:
- You’ve defined a field that other implementers are likely to need
- An existing field in the catalog doesn’t quite fit your use case (propose a modification)
- You want to help standardize a field that multiple implementations are already defining independently
How to contribute
Section titled “How to contribute”New custom fields are proposed by opening a pull request against the CommonGrants repository. At a high level, the process involves:
-
Define the field in TypeSpec. Create a new
.tspfile inwebsite/src/specs/custom-fields/that extendsCustomFieldwith your field’s metadata.Here’s the
fiscalYearfield, one of the simpler entries in the catalog:import "@common-grants/core";import "@typespec/json-schema";import "./index.tsp";using CommonGrants.Fields;using JsonSchema;namespace CustomFields.FiscalYear;/** The fiscal year associated with this opportunity */@extension("x-tags", #[Tags.funding])@extension("x-author", "CommonGrants")@extension("x-version", "0.1.0")@extension("x-valid-schemas", #[ValidSchemas.OpportunityBase])model CustomFiscalYear extends CustomField {name: "fiscalYear";fieldType: CustomFieldType.integer;@example(2025)value: integer;description: "The fiscal year associated with this opportunity";} -
Register the field. Add an import for your new file in
website/src/specs/custom-fields/index.tspand add an entry towebsite/src/content/custom-fields/index.jsonwith a schema reference and timestamps. -
Open a pull request. Submit your PR following the contributing guidelines. Include context about why the field is useful and which implementations could benefit from it.
For detailed instructions on building and testing the website locally (including compiling TypeSpec and previewing your changes), see the website’s DEVELOPMENT.md.
Best practices
Section titled “Best practices”- Check the catalog first. Before defining a new field, browse the custom field catalog to see if a similar field already exists. If one is close but not quite right, consider proposing a modification instead.
- Use descriptive names. Field names should clearly convey their purpose. Prefer specific names like
federalOpportunityNumberover generic ones likerefNumber. - Choose the right type. Use the most specific
CustomFieldTypefor your field’s value. For structured data, useobjectwith well-documented properties. - Tag appropriately. Use the tags defined in
index.tsp(e.g.,identifier,federal,organization) to help users discover your field through search and filtering. - Specify valid schemas. Indicate which base models your field applies to (e.g.,
OpportunityBase,CompetitionBase) using thex-valid-schemasextension. - Include examples. Add
@exampledecorators so that users can see realistic sample values in the catalog and generated documentation.