The CommonGrants protocol defines a standard set of fields for grants data, but agencies and systems often need to track additional information beyond that core set. Extensions are the mechanism for adding these agency- or system-specific fields to CommonGrants resources without modifying the base specification.
For background, see:
- Custom Fields catalog: The published set of recommended custom fields
- Extensions section of the CommonGrants specification: How extensions fit into the protocol
The @common-grants/sdk/extensions module provides TypeScript utilities for working with extensions: registering custom fields on base schemas, bundling them into reusable plugins, and composing plugins together.
- Key concepts
- Extending base models with custom fields
- Extracting custom field values
- Plugins
- Using plugins with the API client
- Plugin transformations
- Plugin custom filters
- Best practices
- API reference
Here are some key concepts that are used to define custom fields and plugins that extend base schemas from the CommonGrants protocol.
| Concept | Description |
|---|---|
| Custom field | A key-value pair attached to a resource's customFields property. Each field has a name, fieldType, value, and optional description. |
CustomFieldSpec |
A TypeScript object that describes a custom field: its fieldType, optional value (a Zod schema for validating the custom field's value), and optional name and description. |
Plugin |
An object returned by definePlugin() with .schemas (per-object compiled output: .commonSchema Zod schema, .sourceSchema, .toCommon, .fromCommon) and optional .meta. |
There are two ways to register custom fields on a base schema: at runtime (ad hoc) or at build-time (with plugins). Both produce Zod schemas with fully typed customFields.
Use withCustomFields() when you want to extend a single schema directly, without creating a reusable plugin. This is useful for one-off scripts, tests, or quick prototyping.
import { z } from "zod";
import { OpportunityBaseSchema } from "@common-grants/sdk/schemas";
import { withCustomFields } from "@common-grants/sdk/extensions";
// Define a Zod schema for a complex custom field value
const LegacyIdValueSchema = z.object({
system: z.string(),
id: z.number().int(),
});
// Extend the base schema with typed custom fields
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);
// Parse data: customFields are now fully typed
const opportunity = OpportunitySchema.parse(data);
opportunity.customFields?.legacyId?.value.id; // number
opportunity.customFields?.category?.value; // stringKey points:
as conston the specs is only needed when you assign them to a variable before the call; inline specs are inferred correctly without it. Forgetting it on a hoisted variable is a compile error. See When you needas constfor details.- If a
valueZod schema is provided in the spec, the custom field'svalueproperty is typed according to that schema. Otherwise, a default type is inferred fromfieldType(e.g."string"->string,"integer"->number). - Unregistered custom fields still pass through validation but are typed as the base
CustomFieldtype (withvalue: unknown).
Use definePlugin() when you want to create a reusable, shareable set of custom field definitions. Plugins are the recommended approach for any extensions that will be used across multiple files, projects, or teams.
import { definePlugin } from "@common-grants/sdk/extensions";
const legacyPlugin = definePlugin({
schemas: {
Opportunity: {
customFields: {
legacyId: {
fieldType: "object",
value: LegacyIdValueSchema,
description: "Maps to the opportunity_id in the legacy system",
},
},
},
},
} as const);
// The plugin exposes typed schemas for every extensible model
const opportunity = legacyPlugin.schemas.Opportunity.commonSchema.parse(data);
opportunity.customFields?.legacyId?.value.id; // numberSee the Plugins section below for full details on defining, composing, and publishing plugins.
There are two ways to access custom field values: direct dot notation and the getCustomFieldValue() helper.
When data has been parsed through an extended schema (via withCustomFields() or a plugin), custom field values are fully typed and can be accessed directly:
const opp = OpportunitySchema.parse(data);
// Typed access — no helper needed
opp.customFields?.legacyId?.value.id; // number
opp.customFields?.category?.value; // stringThis is the simplest approach when you've already validated the data through the extended schema.
Use getCustomFieldValue() when you need to validate a custom field value against a Zod schema at runtime. This is useful in three main situations:
- Unregistered fields: you didn't use
withCustomFields()or a plugin to register a custom field, and want to validate its value at runtime. - External data: the data came from an external source and may not have been parsed through the extended schema.
- Programmatic access: you need to access fields by variable name, such as in a loop.
The helper returns undefined if the key is absent (no try/catch needed) and throws a ZodError if the value is present but doesn't match the schema.
import { getCustomFieldValue } from "@common-grants/sdk/extensions";
const opp = OpportunitySchema.parse(data);
// Returns { system: string; id: number } | undefined
const legacyId = getCustomFieldValue(opp, "legacyId", LegacyIdValueSchema);
if (legacyId) {
console.log(legacyId.id); // typed as number
}
// Returns string | undefined
const category = getCustomFieldValue(opp, "category", z.string());
// Returns undefined — no error thrown
const missing = getCustomFieldValue(opp, "nonexistent", z.string());getCustomFieldValue() works with both ad hoc (unregistered) and plugin-based (registered) custom fields.
A plugin is any object that satisfies the Plugin interface:
interface Plugin<T extends PluginSchemasInput = PluginSchemasInput> {
schemas: PluginSchemas<T>;
meta?: PluginMeta;
}The Plugin interface uses structural typing, so any object with the right shape qualifies as a plugin, whether it comes from a local file, a monorepo package, or an installed npm package. There is no base class to extend or registry to sign up for. In practice, you'll almost always create plugins with definePlugin(), which builds .schemas (including the .commonSchema Zod schema) from your schemas input automatically.
For the full interface definition, see define-plugin.ts.
import { z } from "zod";
import { definePlugin } from "@common-grants/sdk/extensions";
const LegacyIdValueSchema = z.object({
system: z.string(),
id: z.number().int(),
});
const myPlugin = definePlugin({
schemas: {
Opportunity: {
customFields: {
legacyId: {
fieldType: "object",
value: LegacyIdValueSchema,
description: "Maps to the opportunity_id in the legacy system",
},
category: {
fieldType: "string",
description: "Grant category",
},
priority: {
fieldType: "integer",
description: "Processing priority (1 = highest)",
},
},
},
},
} as const);Important
Always pass as const to the options object for definePlugin() (and the specs object for withCustomFields()) as a safe default. Without it, TypeScript can widen literal types like "string" to string, which prevents the type system from inferring the correct value type for each custom field. It is strictly required only when you assign the specs to a variable before the call; see When you need as const for the details.
The returned Plugin object has two properties:
myPlugin.schemas: a record of per-object compiled output, one entry per extensible model. Each entry has:.commonSchema— the Zod schema with typedcustomFieldsapplied (use this to parse data)..sourceSchema,.toCommon,.fromCommon— populated when transforms are configured.
myPlugin.meta: optional plugin identity (name,version,sourceSystem,capabilities).
A minimal plugin package has one source file and two config files:
my-plugin/
src/
index.ts # Plugin definition and package entry point
tsconfig.json # Must emit declaration files for type inference
package.json # Declares @common-grants/sdk as a peer dependency
src/index.ts defines and exports the plugin:
import { z } from "zod";
import { definePlugin } from "@common-grants/sdk/extensions";
export const ProgramAreaValueSchema = z.object({
code: z.string(),
name: z.string(),
});
const plugin = definePlugin({
schemas: {
Opportunity: {
customFields: {
programArea: {
fieldType: "object",
value: ProgramAreaValueSchema,
description: "The HHS program area for this opportunity",
},
cfda: {
fieldType: "string",
description: "CFDA number",
},
},
},
},
} as const);
export default plugin;tsconfig.json must emit declaration files so consumers get type inference:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}package.json:
{
"name": "@commongrants/hhs-plugin",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"peerDependencies": {
"@common-grants/sdk": "^0.3.0"
},
"devDependencies": {
"@common-grants/sdk": "^0.3.0",
"typescript": "^5.0.0",
"zod": "^3.25.0"
}
}-
Build the package to generate
.jsand.d.tsfiles indist/. -
Verify type inference: import your plugin in a test file and confirm that
.schemasparse types resolve correctly. Hover over the types in your editor to confirm they are notany:import plugin from "./"; plugin.schemas.Opportunity.commonSchema.parse({} as any); // fully typed result
-
Publish with
npm publish(or your preferred registry workflow).
After installing the plugin (e.g. npm install @commongrants/hhs-plugin):
import hhs from "@commongrants/hhs-plugin";
const opp = hhs.schemas.Opportunity.commonSchema.parse(data);
opp.customFields?.programArea?.value.code; // string
opp.customFields?.cfda?.value; // stringBuild a client from the plugin with plugin.getClient(). The returned client is pre-bound to the plugin: responses parse with the plugin's compiled schemas by default, and search({ filters }) types the registered filter names — no per-call schema needed:
import { Auth } from "@common-grants/sdk/client";
import { definePlugin, F } from "@common-grants/sdk/extensions";
const myPlugin = definePlugin({
schemas: {
Opportunity: {
customFields: {
legacyId: { fieldType: "integer", description: "Legacy system ID" },
category: { fieldType: "string", description: "Grant category" },
},
},
},
routes: {
opportunities: {
search: { filters: { agency: { filterType: "stringArray" } } },
},
},
} as const);
const client = myPlugin.getClient({
baseUrl: "https://api.example.gov",
auth: Auth.apiKey("your-api-key"),
});
// Get a single opportunity — custom fields are typed, no `schema` option needed
const opp = await client.opportunities.get(oppId);
opp.customFields?.legacyId?.value; // typed as number
opp.customFields?.category?.value; // typed as string
// Search with a registered custom filter — `agency` autocompletes and its
// value family is compile-checked
const results = await client.opportunities.search({
query: "health",
statuses: ["open"],
filters: { agency: F.in(["HHS"]) },
});
// Valid rows land in `items`; rows that fail schema parsing land in `errors`
// (each with its row `index` and `raw` payload) instead of throwing the page.
for (const opp of results.items) {
console.log(opp.customFields?.category?.value);
}
for (const err of results.errors) {
// `err.raw` is the entire unvalidated row and may carry PII — redact before
// logging (same caveat as `TransformError.sourceValue` above).
console.warn(`row ${err.index} failed to parse: ${err.error.message}`);
}The per-call schema option is still accepted by get(), list(), and search() and overrides the plugin-bound default. A plain new Client(...) (no plugin) keeps working with OpportunityBaseSchema defaults (untyped customFields, ad-hoc filters only).
Plugins can declare bidirectional transforms that convert between a source system's native shape and the CommonGrants protocol. toCommon maps native → CommonGrants; fromCommon reverses it. Both directions are author-provided — the SDK does not invert one into the other, because many-to-one handlers (like match) are not reversible.
Use buildTransforms() to compile a pair of mapping objects into typed callables:
import { buildTransforms } from "@common-grants/sdk/extensions";
const { toCommon, fromCommon } = buildTransforms(
// toCommonMapping: native → CommonGrants
{
id: { field: "data.opportunity_uuid" },
title: { field: "data.opportunity_title" },
description: { field: "data.opportunity_description" },
createdAt: { field: "data.created_at" },
lastModifiedAt: { field: "data.last_modified_at" },
status: {
value: {
match: {
field: "data.opportunity_status",
case: { posted: "open", archived: "closed", forecasted: "forecasted" },
default: "custom",
},
},
},
},
// fromCommonMapping: CommonGrants → native
{
data: {
opportunity_uuid: { field: "id" },
opportunity_title: { field: "title" },
},
}
);
const result = toCommon(sourceData);
if (result.errors.length === 0) {
use(result.result);
}Each callable returns a TransformResult<T> of { result, errors } unconditionally. Partial failures surface as TransformError[] rather than thrown exceptions — consumers choose their own strict-vs-lenient rule.
Mapping objects are nested literals where keys are either output field names or registered handler names. The handler-keyed node dispatches the handler with (data, handlerArg). Bare primitives are treated as literals.
| Handler | Spec shape | Behavior |
|---|---|---|
field |
{ field: "dot.notation.path" } |
Plucks a value from the source via dot-notation. Terminal null is preserved; absent / intermediate-null returns undefined. See Null handling. |
const |
{ const: <literal> } |
Returns the literal value, ignoring source data. |
match |
{ match: { field, case: { ... }, default? } } |
Case-based lookup on a source field value. null source passes through unchanged; opt-in target-side translation via case: { "null": ... }. |
switch |
Same as match |
Convenience alias for match — both point at the same handler function. |
numberToString |
{ numberToString: "dot.notation.path" } |
Plucks a value and coerces to string via String(). Returns null on null source ("doesn't apply"); undefined on absent. |
stringToNumber |
{ stringToNumber: "dot.notation.path" } |
Plucks a value, parses as int when possible, falls back to float. Returns null on null source; undefined on absent. Throws on non-numeric input. |
Optional values carry three distinct states on the wire, each preserved through the transform:
| State | Meaning | Handler output |
|---|---|---|
| absent | "Not provided" — the publisher did not supply this data | undefined |
null |
"Doesn't apply" — the publisher actively asserts the field is irrelevant for this record | null |
| value | "Has a value" | coerced value |
The built-in coercing handlers (numberToString, stringToNumber) pass null through unchanged instead of collapsing it to undefined. field defers to getFromPath, which preserves terminal null and treats an intermediate null as a propagating absence ({ a: null } at path "a.b" → undefined).
match / switch adds an opt-in for target-side translation. By default a null source passes through:
// status = null on input → status = null on output (publisher's "doesn't apply" survives)
{ match: { field: "status", case: { posted: "open", archived: "closed" }, default: "custom" } }To translate "doesn't apply" into a target-side sentinel (e.g. an n_a status token), opt in via a "null" case key:
// status = null on input → status = "n_a" on output (author-chosen translation)
{ match: { field: "status", case: { posted: "open", "null": "n_a" }, default: "custom" } }default is not consulted for null source values — default belongs to "unrecognized value," not to "publisher asserts irrelevant." The opt-in "null" case key is the only path from null source to a non-null target.
For custom-handler authors: preserve the three-state contract when you write your own handlers. Return undefined for "not provided," return null for "doesn't apply," return a value otherwise. The walker omits keys whose handler returned undefined and writes null returns as a real, present null — so the output object distinguishes the three states the same way the wire does: absent → key omitted, null → present null, value → present value. Consumers can check for key presence to tell "not provided" from "doesn't apply."
Cross-SDK note. The TypeScript SDK preserves the three-state distinction (absent /
null/ value) at the transform layer. The Python SDK currently collapsesNonesource into the "not provided" path for the coercing handlers — bringing them to parity is a pending follow-up.
Register additional handlers per buildTransforms() call. Name collisions with built-ins raise at call time. Custom handlers should follow the three-state contract from Null handling above:
import { buildTransforms, getFromPath } from "@common-grants/sdk/extensions";
// `join` is a special case: string concatenation has no meaningful null
// behavior, so the filter below drops both `undefined` and `null` source
// values. This is appropriate for `join` specifically — most coercing
// handlers should follow `numberToString` / `stringToNumber` and preserve
// the three-state contract (return `null` on null source, `undefined` on
// absent) instead.
const join = (data: unknown, spec: unknown) => {
const s = spec as { fields?: string[]; sep?: string };
const parts = (s.fields ?? [])
.map(p => getFromPath(data, p))
.filter(v => v !== undefined && v !== null)
.map(String);
return parts.length > 0 ? parts.join(s.sep ?? " ") : undefined;
};
const { toCommon } = buildTransforms(
{ label: { join: { fields: ["a.b", "c.d"], sep: " — " } } },
{},
new Map([["join", join]])
);Pass an optional commonSchema to validate toCommon output. Use the fully extended schema (the result of withCustomFields()), not the base schema — passing the base silently weakens validation of typed custom fields:
import { buildTransforms, withCustomFields } from "@common-grants/sdk/extensions";
import { OpportunityBaseSchema } from "@common-grants/sdk/schemas";
const ExtendedOpportunity = withCustomFields(OpportunityBaseSchema, {
legacyId: { fieldType: "integer", value: z.number().int() },
});
const { toCommon } = buildTransforms(
{
/* toCommonMapping ... */
},
{},
undefined,
ExtendedOpportunity
);
const out = toCommon(sourceData);
// On validation failure, `out.result` holds the raw transformed object so
// callers can inspect malformed data alongside `out.errors`.There are two ways to wire transforms into a plugin entry. These are mutually exclusive — you cannot provide both mappings and explicit callables on the same entry.
Option A: Declarative mappings — pass a mappings object and definePlugin() compiles toCommon / fromCommon for you:
const plugin = definePlugin({
meta: {
name: "grants.gov",
sourceSystem: "grants.gov",
capabilities: ["customFields", "transforms"],
},
schemas: {
Opportunity: {
sourceSchema: GrantsGovOpportunity,
customFields: {
/* customFieldSpecs */
},
mappings: {
toCommon: {
id: { field: "data.opportunity_uuid" },
title: { field: "data.opportunity_title" },
status: {
value: {
match: {
field: "data.opportunity_status",
case: { posted: "open", archived: "closed" },
default: "custom",
},
},
},
},
fromCommon: {
data: {
opportunity_uuid: { field: "id" },
opportunity_title: { field: "title" },
},
},
},
},
},
} as const);Option B: Hand-written callables — pass toCommon / fromCommon directly for logic that declarative mappings cannot express:
const plugin = definePlugin({
meta: {
name: "grants.gov",
sourceSystem: "grants.gov",
capabilities: ["customFields", "transforms"],
},
schemas: {
Opportunity: {
sourceSchema: GrantsGovOpportunity,
customFields: {
/* customFieldSpecs */
},
toCommon,
fromCommon,
},
},
} as const);Both options expose the same runtime surface:
const { result, errors } = plugin.schemas.Opportunity.toCommon(sourceData);For a complete runnable round-trip covering both options, custom handlers, and commonSchema validation, see examples/transforms.ts (pnpm example:transforms).
TransformError carries structured context — path, handler, sourceValue, cause — so consumers can reason about failures programmatically without parsing error text:
const out = toCommon(sourceData);
for (const err of out.errors) {
// Build a redacted projection — sourceValue carries the full input record.
// See the PII warning below before logging the full error object.
const safe = { name: err.name, path: err.path, handler: err.handler };
console.warn(safe);
}TransformError properties:
| Property | Type | Description |
|---|---|---|
message |
string |
Human-readable description of the failure. |
path |
string | undefined |
Dot-notation path to the failing field, when known. |
handler |
string | undefined |
Handler name that threw, when applicable. |
sourceValue |
unknown |
The full input record passed to the transform (see PII warning). |
cause |
unknown |
The original exception. |
PII warning: The SDK does not redact by default.
TransformError.sourceValueandcauseare plain enumerable fields and flow throughJSON.stringify(err),util.inspect(err),console.log(err), and any logger that enumerates own properties.sourceValueis populated with the entire input record passed totoCommon/fromCommon— not just the value at the failing field. On the Zod-validation path (whencommonSchemais passed tobuildTransforms()),TransformError.messageis also data-bearing — Zod's default error map embeds the rejected value intoissue.message. Log a redacted projection instead — e.g.{ name: err.name, path: err.path, handler: err.handler }.
Plugins can declare custom filter specs per route-method. At search time, a single flat consumer filters object is classified into the body of the search request sent to the API (the OppFilters shape from ADR-0012): default fields land as named top-level fields; custom and ad-hoc keys land under customFilters.
Important
Custom filters attach to resource methods (routes), not to schemas. Custom fields attach to schemas. These two extension points use different keys on the same definePlugin() call:
definePlugin({
routes: { opportunities: { search: { filters: { ... } } } }, // custom filters (this section)
schemas: { Opportunity: { customFields: { ... } } }, // custom fields (see "Build-time with plugins")
} as const);The reason: filter declarations vary per route-method (e.g. opportunities.search may support different filters than applications.list), whereas custom fields are schema-level concerns shared across all operations that return the model.
Pass routes alongside (or instead of) schemas in definePlugin(). Always add as const — it is load-bearing for compile-time narrowing (see The as const trap):
import { definePlugin } from "@common-grants/sdk/extensions";
const grantsGovPlugin = definePlugin({
meta: {
name: "grants.gov",
version: "0.1.0",
sourceSystem: "grants.gov",
capabilities: ["customFilters"],
},
routes: {
opportunities: {
search: {
filters: {
agency: {
filterType: "stringArray",
description: "Filter by funding agency code (e.g. 'HHS', 'DOE')",
},
fundingProgram: {
filterType: "stringComparison",
description: "Filter by funding program name",
},
},
},
},
},
} as const); // ← `as const` is load-bearing — see "The as const trap" belowEach filter entry is a CustomFilterSpec: { filterType: CustomFilterType; description?: string }. Operators are derived from filterType — you do not author them.
CustomFilterType is an 11-value enum. Each value maps to a base filter schema with auto-derived operators:
filterType |
Operators | Value shape |
|---|---|---|
stringComparison |
eq, neq, like, notLike |
string |
stringArray |
in, notIn |
string[] |
numberComparison |
eq, neq, gt, gte, lt, lte |
number |
numberArray |
in, notIn |
number[] |
numberRange |
between, outside |
{ min, max } |
booleanComparison |
eq, neq |
boolean |
dateComparison |
gt, gte, lt, lte |
string (ISO) |
dateRange |
between, outside |
{ min, max } |
moneyComparison |
gt, gte, lt, lte |
Money |
moneyRange |
between, outside |
{ min, max } |
The F namespace provides helpers that compile { operator, value } filter objects without manual construction:
import { F } from "@common-grants/sdk/extensions";
F.eq("open"); // { operator: "eq", value: "open" }
F.in(["HHS", "DOE"]); // { operator: "in", value: ["HHS", "DOE"] }
F.like("*Conservation*"); // { operator: "like", value: "*Conservation*" }
F.between("2025-01-01", "2025-12-31"); // { operator: "between", value: { min: "2025-01-01", max: "2025-12-31" } }
// Full set: eq, neq, gt, gte, lt, lte, in, notIn, like, notLike, between, outsideCross-SDK note. TypeScript uses
F.in("in"as an object key — valid JS). The Python sibling SDK (#869) usesf.in_(trailing underscore, Python convention for reserved words). This is a documented naming divergence across SDKs.
definePlugin() registers filter declarations — which filter names exist and what type each one is. classifyFilters() is the separate search-time step: it takes the consumer's actual filter values and produces the body of the request sent to the API. The client calls it internally on every search(); consumers normally never call it directly.
classifyFilters() accepts the plugin's routes, a resource name, a method name, and the consumer's flat filters object. It returns an OppFilters request body conforming to ADR-0012 — and it is fail-fast: an invalid value on any key (standard, registered, or ad-hoc) throws FilterError before a request body exists:
import { classifyFilters } from "@common-grants/sdk/extensions";
const consumerFilters = {
// Default filters → top-level named fields on the request body
status: F.in(["open", "forecasted"]),
closeDateRange: F.between("2025-01-01", "2025-12-31"),
// Pre-registered custom filters → customFilters record
agency: F.in(["HHS", "DOE"]),
fundingProgram: F.like("*Conservation*"),
// Ad-hoc filter (not declared in the plugin) → customFilters passthrough
legacyTag: F.eq("conservation-2024"),
};
const requestBody = classifyFilters(
grantsGovPlugin.routes!, // routes is optional on Plugin; assert non-null when known
"opportunities",
"search",
consumerFilters
);
// requestBody shape (ADR-0012):
// {
// status: { operator: "in", value: ["open", "forecasted"] },
// closeDateRange: { operator: "between", value: { min: "2025-01-01", max: "2025-12-31" } },
// customFilters: {
// agency: { operator: "in", value: ["HHS", "DOE"] },
// fundingProgram: { operator: "like", value: "*Conservation*" },
// legacyTag: { operator: "eq", value: "conservation-2024" },
// }
// }The three-bucket classification rule (ADR-0012):
- Default filters (
status,closeDateRange, etc.) → named top-level fields on the request body. - Pre-registered custom filters (declared in
routes.*.*.filters) →customFiltersrecord, with operator/value-shape validation against the declaredfilterType. - Ad-hoc filters (not declared, including
gov.<system>@<filterName>namespaced keys) →customFilterspassthrough, shape-only validated.
For a complete runnable example with assertions, see examples/custom-filters.ts (pnpm example:custom-filters).
validateRoutes() is called at registration time. It throws FilterError if:
- A filter spec uses an unknown
filterTypevalue. - A custom filter name collides with a default filter field name (e.g. registering
"status"would shadow the protocol's standardstatusfilter).
Call-time validation runs automatically inside classifyFilters() for every filter key — registered filters are checked for operator/value shape against the declared filterType, ad-hoc filters get a shape-only check (DefaultFilterSchema). Any failure throws FilterError before the request is sent; filterInfo.errors on the response carries server-returned errors only.
definePlugin() runs validateRoutes() at definition time, so a misspelled route or an invalid filter registration throws at the definition site (the compile-time closed route keys catch the same mistakes for TypeScript authors).
PII note: as with transforms,
FilterError.sourceValuecarries the raw input — here, the consumer's filter value. The PII warning above applies equally; log a redacted projection.
import { validateRoutes } from "@common-grants/sdk/extensions";
// Registration-time — throws FilterError on unknown filterType or collision
validateRoutes(grantsGovPlugin.routes!);Important
Always pass as const to definePlugin() when declaring routes. Without it, TypeScript widens literal filterType values from specific strings (e.g. "stringArray") to the broad string type, and the TypedConsumerFilters narrowing layer is lost — unknown filter keys, wrong operators, and wrong value shapes then silently pass the type checker.
The compile-time proof is in __tests__/extensions/custom-filters-types.ts — six @ts-expect-error directives guard actual compile errors that fire only while as const narrowing is in effect, checked by tsc --noEmit.
Shortcut: if you write fieldType using the CustomFieldType enum (e.g. CustomFieldType.integer), you never need as const. Enum values are already fixed literals and don't widen. The rest of this section only matters if you type fieldType as a raw string literal like "integer".
For raw string literals, as const is only required when you assign the specs (or the whole options object) to a variable before passing them to definePlugin() or withCustomFields(). Written inline in the call, you don't need it.
Why: each spec's fieldType needs to stay a literal ("integer", "string") for the type system to work. A raw string literal passed inline is kept literal for you by the function. But stored in a variable first, TypeScript infers the variable's type on its own and widens the raw literal "integer" to string. A widened fieldType can no longer determine the field's value type, so value falls back to unknown. (This only affects fields that rely on the default value type. Fields that pass an explicit value schema keep their type either way, but it's simplest to apply as const whenever you hoist raw-literal specs. Enum values, as noted above, never widen.)
You typically hoist specs into a variable to reuse them, for example to feed typeof customFields into the ToCommon / FromCommon transform helper types.
// OK (inline, no `as const` needed):
definePlugin({
schemas: { Opportunity: { customFields: { legacyId: { fieldType: "integer" } } } },
});
// Needs `as const` (specs hoisted into a variable):
const customFields = { legacyId: { fieldType: "integer" } } as const;
definePlugin({ schemas: { Opportunity: { customFields } } });If you hoist the specs and forget as const, the call still fails to compile. The message is TypeScript's built-in one: the widened fieldType (now string) is no longer a valid CustomFieldType. Adding as const resolves it.
const customFields = { legacyId: { fieldType: "integer" } }; // no `as const`
definePlugin({ schemas: { Opportunity: { customFields } } });
// Error: Type '{ fieldType: string; ... }' is not assignable to type 'CustomFieldSpec'.
// Type 'string' is not assignable to type '"string" | "number" | "integer" | ...'.(Adding as const to an inline call is harmless, just unnecessary.)
When you define Zod schemas for complex value fields, export them as named exports from your package. Downstream consumers may need these schemas for use with utilities like getCustomFieldValue():
// index.ts of a plugin package
import { z } from "zod";
import { definePlugin } from "@common-grants/sdk/extensions";
// Export value schemas so consumers can reference them directly
export const ProgramAreaValueSchema = z.object({
code: z.string(),
name: z.string(),
});
const plugin = definePlugin({
schemas: {
Opportunity: {
customFields: {
programArea: {
fieldType: "object",
value: ProgramAreaValueSchema,
description: "The HHS program area for this opportunity",
},
},
},
},
} as const);
export default plugin;This allows consumers to use getCustomFieldValue() with the same schema the plugin uses for validation:
import hhs, { ProgramAreaValueSchema } from "@commongrants/hhs-plugin";
import { getCustomFieldValue } from "@common-grants/sdk/extensions";
const opp = hhs.schemas.Opportunity.commonSchema.parse(data);
// Extract the value with full type safety using the exported schema
const area = getCustomFieldValue(opp, "programArea", ProgramAreaValueSchema);
area?.code; // stringDeclare @common-grants/sdk as a peerDependency in your plugin's package.json rather than a direct dependency. This ensures that consumers who install multiple plugins all share a single copy of the SDK, avoiding version conflicts and duplicate type definitions. See Publishing a plugin for a full package.json example.
A plugin should represent a single logical concern (one agency's fields, one integration's needs, or one domain concept). If you need fields from multiple concerns, define a combined plugin with all fields declared under a single definePlugin({ schemas: { ... } }) call rather than splitting them across separate plugins.
The tables below list everything exported from @common-grants/sdk/extensions, grouped by how they're used. Each entry links to the source definition and (where applicable) the section of this guide where it's demonstrated.
| Export | Kind | Description | Demonstrated in |
|---|---|---|---|
definePlugin() |
function | Creates a Plugin from DefinePluginOptions. Returns an object with .schemas (per-object output: .commonSchema, .sourceSchema, .toCommon, .fromCommon) and optional .meta. |
Defining a plugin |
Plugin |
interface | The object returned by definePlugin(). |
What is a plugin? |
DefinePluginOptions |
interface | Options for definePlugin(). schemas carries all per-object input (custom fields, native schema, declarative mappings, transforms); meta is plugin identity. |
Defining a plugin |
| Export | Kind | Description | Demonstrated in |
|---|---|---|---|
withCustomFields() |
function | Extends a single Zod object schema with typed custom fields. Unregistered fields pass through but are typed as the base CustomField. |
Ad hoc with withCustomFields() |
WithCustomFieldsResult |
type | The return type of withCustomFields(). A Zod object schema where customFields is replaced with a typed version. |
|
getCustomFieldValue() |
function | Safely extracts and parses a custom field value from an ExtensibleObject. Returns the parsed value, undefined if missing, or throws ZodError if invalid. |
Extracting custom field values |
| Export | Kind | Description | Demonstrated in |
|---|---|---|---|
buildTransforms() |
function | Compiles a pair of mapping objects into typed (toCommon, fromCommon) callables. Positional params: (toCommonMapping, fromCommonMapping, handlers?, commonSchema?, sourceSchema?). Validates mapping structure at call time; collisions with built-ins throw. |
Defining bidirectional transforms |
BuiltTransforms |
interface | Return shape of buildTransforms() — { toCommon, fromCommon }. |
|
transformFromMapping() |
function | Low-level mapping walker used by buildTransforms(). Useful if you want to drive a single mapping pass without the call-time validation or error-wrapping layer. |
|
TransformFromMappingOptions |
interface | Options for transformFromMapping(): optional handlers registry (Map<string, Handler>). |
|
DEFAULT_HANDLERS |
const | Map<string, Handler> of built-in handlers: const, field, match, numberToString, stringToNumber, switch. |
Built-in mapping handlers |
getFromPath() |
function | Walks an object via dot-notation; returns undefined (or a provided default) when the path is missing or traverses a non-object. |
|
TransformResult |
interface | Unconditional return shape { result, errors } for toCommon / fromCommon. |
Defining bidirectional transforms |
TransformError |
class | Structured transformation error carrying path, handler, sourceValue, cause. Extends Error. |
Error handling |
Handler |
type | Signature for mapping handler functions: (data, arg) => unknown. |
Custom handlers |
PluginMeta |
interface | Plugin identity: name (required), sourceSystem (required), optional version and capabilities. |
Wiring transforms into a plugin |
PluginCapability |
type | Literal union of capability names: "customFields" | "customFilters" | "transforms". |
|
PluginSchemasInput |
type | Map from extensible model name to SchemaInput. The shape of DefinePluginOptions.schemas. |
Wiring transforms into a plugin |
SchemaInput |
type | Author-provided input per object: { sourceSchema?, customFields?, mappings? } or { sourceSchema?, customFields?, toCommon?, fromCommon? }. XOR: mappings and explicit callables cannot both be present. |
Wiring transforms into a plugin |
SchemaOnly |
interface | Compiled output for schema-only entries: { commonSchema, sourceSchema? }. Produced when no transforms are configured. |
|
SchemaWithTransforms |
interface | Compiled output for entries with transforms: { commonSchema, sourceSchema?, toCommon, fromCommon }. Produced when mappings or explicit callables are provided. |
|
SchemaMappings |
interface | Declarative { toCommon?, fromCommon? } mapping dicts. Stored inside SchemaInput.mappings. |
|
TransformTypes |
interface | Named argument for ToCommon / FromCommon: { model, sourceSchema, customFields? }. model selects the base schema; the common type is resolved from customFields. |
|
ToCommon |
type | Helper type for a hand-written toCommon. Takes a TransformTypes arg; source is typed from sourceSchema, the return checked against the resolved common input type. |
|
FromCommon |
type | Helper type for a hand-written fromCommon. Takes a TransformTypes arg; common is the resolved common output type, the return the source type. |
| Export | Kind | Description | Demonstrated in |
|---|---|---|---|
classifyFilters() |
function | Fail-fast three-bucket classifier. Maps a flat consumer filters object to the ADR-0012 OppFilters request body (default fields → top-level named fields; registered custom + ad-hoc → customFilters record); throws FilterError on the first invalid value. |
Classifying consumer filters |
validateRoutes() |
function | Registration-time validator. Throws FilterError on unknown filterType or default-field name collisions. |
Validation |
F |
namespace | Helper namespace. F.eq, F.neq, F.gt, F.gte, F.lt, F.lte, F.in, F.notIn, F.like, F.notLike, F.between, F.outside — each compiles to { operator, value }. Note: F.in is "in" as an object property key. |
Filter-type catalog and the F.* helpers |
CustomFilterSpec |
interface | Per-filter declaration: { filterType: CustomFilterType; description?: string }. Operators are derived from filterType; no value field. |
Declaring custom filters on a route |
CustomFilterType |
type | 10-value literal union: stringComparison | stringArray | numberComparison | numberArray | numberRange | booleanComparison | dateComparison | dateRange | moneyComparison | moneyRange. |
Filter-type catalog |
PluginRoutes |
type | Partial<Record<ResourceName, RouteMethods>> — the routes value on DefinePluginOptions. Both key levels are closed unions, so a misspelled resource or method is a compile error. |
Declaring custom filters on a route |
RouteDeclarations |
interface | Per-method filter map: { filters?: Record<string, CustomFilterSpec> }. |
| Export | Kind | Description | Demonstrated in |
|---|---|---|---|
CustomFieldSpec |
interface | Describes a single custom field: its fieldType, optional value, and optional name/description. |
Key concepts |
ExtensibleSchemaName |
type | Union of model names that support customFields extensions. Currently: "Opportunity". |
|
HasCustomFields |
type | A Zod object schema whose shape includes a customFields property. Constrains withCustomFields() inputs at compile time. |
|
ExtensibleObject |
interface | An object with an optional customFields property. Constrains getCustomFieldValue() inputs at compile time. |