You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
These are some of the major design decisions that would be good to get a 👍🏿 or 👎🏿 from the rest of the team
This note was drafted by Claude AI, but heavily edited. So some effort was put into writing it, hopefully some effort will be put into reading it. If you're too busy, then they're ordered in order of importance to decide on, give an opinion for the first ones, at least (please also give an indication if your opinion is that you don't care either way!)
If there other non-documented gaps in the library, then this is also the place to bring them up
1. Limitations of type inference for app-runtime queries
DeriveResourceTypeMap + InferQueryResult were added to assist with type inference in app-runtime. This is documented in this ADR and their usage here.
Code using useDataQuery looks like this, and the type for data is automatically infered.
// infered types look like thisimport{useDataQuery}from'@dhis2/app-runtime'const{ data }=useDataQuery({dataElements: {resource: 'dataElements',params: {fields: ['id','name','valueType']asconst},},}asconst)// data?.dataElements.pager.page → number// data?.dataElements.dataElements[0].id → string | undefined// data?.dataElements.dataElements[0].valueType → ValueType (enum, not just string)
This has many constraints though:
Non-paginated / single-resource responses. Fetching a single object by ID — e.g. resource: "dataElements/fbfJHSPpUQD" — is a path-parameter endpoint and is excluded by design. InferQueryResult returns unknown for it. Callers must type these manually.
Tracker resources. The spec types tracker responses as an opaque Page object, not a typed array, so they fall out of the auto-derived map. To get typed tracker results, callers must extend the map manually:
Deeply nested field filters.PickWithFieldFilters silently drops fields that don't exist on the declared type. Because references are typed as IdentifiableObject, a filter like categoryCombo[categories[id,name]] only returns the fields IdentifiableObject has — categories is dropped. This is accurate, but means deeply nested picks give you less than you might expect.
Fields must be as const. The fields array must be a literal type for narrowing to work. params: { fields: ["id", "name"] as const } works; a dynamic array or .join(",") produces string and the full unnarrowed type is returned.
Given the limitations, is this worth adding? Do we invest in improving inference or is it OK if consumers explicitly choose their types? or have limited inference with the ability to override (current situation)
2. The reference shape trade-off
One of the main decisions in the library is that nested references are typed as IdentifiableObject, not the concrete type. This is accurate — the API really does return { id } for a categoryCombo unless you expand it — but it means you cannot write de.categoryCombo.categories without a type error. The flip side is that code like de.categoryCombo.categories[0].displayName cannot silently compile and then throw at runtime when categories wasn't in the ?fields= query.
Considered alternatives:
Resolve references to concrete types (dhis2-open-api-ts approach) — gives named imports and navigable references, but is structurally wrong for any partial response: categoryCombo becomes CategoryCombo with all fields required, which passes type-checking for data that will never arrive unless explicitly requested
Hand-written types — accurate for the specific fields an app uses, but drift from the spec silently and don't scale across teams
3. Utility types excluded for the resolved-reference ecosystem
Several utilities from metadata-management-app were evaluated and excluded: gist link traversal types, GetReferencedModels, PickInModelReferences, and others. These were designed assuming references resolve to distinct concrete types — they return uniform IdentifiableObject output when applied to this library's types, losing their purpose entirely. If teams using that app want to migrate to @dhis2/api-types, they'll hit this gap.
How much of that utility surface is actively relied on.
Considered alternatives:
Include all utilities regardless — rejected because most only make sense with resolved references and would produce misleading results or require a separate resolved-reference build
Include only the ones that work correctly regardless of reference style — this is what was done: GistModel, PickWithFieldFilters, PagedResponse, and Prettify all work against IdentifiableObject-typed references and provide genuine value; the rest were left out
Are there other utilties generic enough to include?
The OpenAPI spec is machine-generated from the Java backend and has errors. The library corrects them by modifying the parsed spec object in generate.ts before type generation, with each patch linked to an upstream DHIS2 issue. Consumer workarounds use Omit + intersection (for wrong field types) or module augmentation (for missing fields only — declaration merging cannot redefine existing properties). Worth raising: how do we coordinate corrections across teams, and what's the process for getting fixes into the upstream spec?
Considered alternatives:
Ship incorrect types and let consumers always work around them locally - rejected because the library would be unreliable and workarounds would multiply across every app independently
Use the openapi-typescripttransform hook for corrections - appropriate for structural changes (e.g. mapping date-time strings to Date objects) but significantly more complex than spec patching; only used when a correction can't be expressed as a JSON change
Fork or vendor the spec - too much maintenance overhead; patching a handful of fields in generate.ts is far lighter
Release and versioning strategy
The package version is independent of DHIS2 API versions. We use semantic-release with conventional commits — merging to main automatically determines the bump from commit prefixes (fix: → patch, feat: → minor, feat!: / BREAKING CHANGE: → major) and publishes to npm. No manual version management. Pre-releases go to beta/alpha branches.
Initially, we went the direction of pinning the major version part of semver, but this conflates two independent release cadences and makes conventional commit automation cumbersome. We used a semi-automated flow for this but it looked awkward and unlike what we're used to in other projects. It also meant that v43 of the library contains types for the previous versions as well, which is also weird potentially for the consumer.
We also considered using Changesets for an extra level of control over releases, but not sure about the benefit of that vs. the different flow (especially for pre-release branches).
Open questions:
We only bump major (i.e. BREAKING_CHANGE) with new DHIS core versions - what if there is a change/fix to the OpenAPI specs?
3.1 Packaging multiple API versions in one package
Related to previous point, the last four DHIS2 API versions (currently v40–v43) are all shipped in a single npm package as separate entry points. Consumers import from the version they target, or omit the version to get the latest:
// Pin to a specific DHIS2 API versionimporttype{DataElement}from"@dhis2/api-types/v42"importtype{components}from"@dhis2/api-types/v42"// Always use the latest (currently v43)importtype{DataElement}from"@dhis2/api-types"
The default is to resolve to latest version (v43), which seems reasonable (?). Adding a new version is a configuration-only change: one entry in scripts/versions.ts, updated package.json exports, and a re-run of npm run update. Dropping the oldest is a major bump.
Considered alternatives:
One npm package per DHIS2 version (@dhis2/api-types-v43): each version is independently versioned and published, but consumers manage multiple packages, there's no shared utility types layer, and coordinating releases across packages adds overhead
A single entry point always tracking latest: simpler, but apps targeting a specific DHIS2 instance lose the ability to pin and get breaking changes on every major DHIS2 release
Keeping all versions forever: unbounded growth; the four-version window matches DHIS2's own LTS support window
Open questions:
How to validate a type against multiple schema versions. An app that must run against DHIS2 v41 through v43 would ideally assert that a given type (or the fields it uses) is compatible across all three versions. There's no established pattern for this yet — options include union-based compatibility checks in type tests, a shared type test file imported under multiple version paths, or a lint rule that flags version-specific imports in shared code.
Named exports and namespace noise
The library exports ~900 named types per version (import type { DataElement } from "@dhis2/api-types/v43") alongside the original components["schemas"] namespace. Common names like Event, User, and Program are now module-level exports — harmless in import type context, but potentially surprising in code review. Both styles are fully interchangeable and the namespace form continues to work unchanged.
We thought we're too used to named types so kept them here, despite a bit of bloat. The alternative would be for consumers to always do something like type DataElement = components["schemas"]["DataElement"] (or use the types from components). Both felt too weird from an ergonomic point of view.
Discussion Points
Important
These are some of the major design decisions that would be good to get a 👍🏿 or 👎🏿 from the rest of the team
1. Limitations of type inference for app-runtime queries
DeriveResourceTypeMap+InferQueryResultwere added to assist with type inference in app-runtime. This is documented in this ADR and their usage here.Code using
useDataQuerylooks like this, and the type fordatais automatically infered.This has many constraints though:
Non-paginated / single-resource responses. Fetching a single object by ID — e.g.
resource: "dataElements/fbfJHSPpUQD"— is a path-parameter endpoint and is excluded by design.InferQueryResultreturnsunknownfor it. Callers must type these manually.Tracker resources. The spec types tracker responses as an opaque
Pageobject, not a typed array, so they fall out of the auto-derived map. To get typed tracker results, callers must extend the map manually:Deeply nested field filters.
PickWithFieldFilterssilently drops fields that don't exist on the declared type. Because references are typed asIdentifiableObject, a filter likecategoryCombo[categories[id,name]]only returns the fieldsIdentifiableObjecthas —categoriesis dropped. This is accurate, but means deeply nested picks give you less than you might expect.Fields must be
as const. The fields array must be a literal type for narrowing to work.params: { fields: ["id", "name"] as const }works; a dynamic array or.join(",")producesstringand the full unnarrowed type is returned.Given the limitations, is this worth adding? Do we invest in improving inference or is it OK if consumers explicitly choose their types? or have limited inference with the ability to override (current situation)
2. The reference shape trade-off
One of the main decisions in the library is that nested references are typed as
IdentifiableObject, not the concrete type. This is accurate — the API really does return{ id }for acategoryCombounless you expand it — but it means you cannot writede.categoryCombo.categorieswithout a type error. The flip side is that code likede.categoryCombo.categories[0].displayNamecannot silently compile and then throw at runtime whencategorieswasn't in the?fields=query.Considered alternatives:
dhis2-open-api-tsapproach) — gives named imports and navigable references, but is structurally wrong for any partial response:categoryCombobecomesCategoryCombowith all fields required, which passes type-checking for data that will never arrive unless explicitly requested3. Utility types excluded for the resolved-reference ecosystem
Several utilities from
metadata-management-appwere evaluated and excluded: gist link traversal types,GetReferencedModels,PickInModelReferences, and others. These were designed assuming references resolve to distinct concrete types — they return uniformIdentifiableObjectoutput when applied to this library's types, losing their purpose entirely. If teams using that app want to migrate to@dhis2/api-types, they'll hit this gap.How much of that utility surface is actively relied on.
Considered alternatives:
GistModel,PickWithFieldFilters,PagedResponse, andPrettifyall work againstIdentifiableObject-typed references and provide genuine value; the rest were left outAre there other utilties generic enough to include?
More context in this ADR
Other decisions and notes
Spec errors and patching
The OpenAPI spec is machine-generated from the Java backend and has errors. The library corrects them by modifying the parsed spec object in
generate.tsbefore type generation, with each patch linked to an upstream DHIS2 issue. Consumer workarounds useOmit+ intersection (for wrong field types) or module augmentation (for missing fields only — declaration merging cannot redefine existing properties). Worth raising: how do we coordinate corrections across teams, and what's the process for getting fixes into the upstream spec?Considered alternatives:
openapi-typescripttransformhook for corrections - appropriate for structural changes (e.g. mappingdate-timestrings toDateobjects) but significantly more complex than spec patching; only used when a correction can't be expressed as a JSON changegenerate.tsis far lighterRelease and versioning strategy
The package version is independent of DHIS2 API versions. We use semantic-release with conventional commits — merging to
mainautomatically determines the bump from commit prefixes (fix:→ patch,feat:→ minor,feat!:/BREAKING CHANGE:→ major) and publishes to npm. No manual version management. Pre-releases go tobeta/alphabranches.Initially, we went the direction of pinning the major version part of semver, but this conflates two independent release cadences and makes conventional commit automation cumbersome. We used a semi-automated flow for this but it looked awkward and unlike what we're used to in other projects. It also meant that
v43of the library contains types for the previous versions as well, which is also weird potentially for the consumer.We also considered using Changesets for an extra level of control over releases, but not sure about the benefit of that vs. the different flow (especially for pre-release branches).
Open questions:
3.1 Packaging multiple API versions in one package
Related to previous point, the last four DHIS2 API versions (currently v40–v43) are all shipped in a single npm package as separate entry points. Consumers import from the version they target, or omit the version to get the latest:
The default is to resolve to latest version (v43), which seems reasonable (?). Adding a new version is a configuration-only change: one entry in
scripts/versions.ts, updatedpackage.jsonexports, and a re-run ofnpm run update. Dropping the oldest is a major bump.Considered alternatives:
@dhis2/api-types-v43): each version is independently versioned and published, but consumers manage multiple packages, there's no shared utility types layer, and coordinating releases across packages adds overheadOpen questions:
Named exports and namespace noise
The library exports ~900 named types per version (
import type { DataElement } from "@dhis2/api-types/v43") alongside the originalcomponents["schemas"]namespace. Common names likeEvent,User, andProgramare now module-level exports — harmless inimport typecontext, but potentially surprising in code review. Both styles are fully interchangeable and the namespace form continues to work unchanged.We thought we're too used to named types so kept them here, despite a bit of bloat. The alternative would be for consumers to always do something like
type DataElement = components["schemas"]["DataElement"](or use the types fromcomponents). Both felt too weird from an ergonomic point of view.More context in this ADR