Skip to content

Discussion points about the library #2

Description

@kabaros

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

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 this
import { useDataQuery } from '@dhis2/app-runtime'

const { data } = useDataQuery({
    dataElements: {
        resource: 'dataElements',
        params: { fields: ['id', 'name', 'valueType'] as const },
    },
} as const)

// 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:

type MyMap = DeriveResourceTypeMap<paths> & {
    "tracker/enrollments": TrackerEnrollment
    "tracker/events": Event
}

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?

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.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-typescript transform 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 version
import type { DataElement } from "@dhis2/api-types/v42"
import type { components } from "@dhis2/api-types/v42"

// Always use the latest (currently v43)
import type { 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.

More context in this ADR

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions