Skip to content

Static types generated from the schema

This page is about the TypeScript library. Python generates types from the same schemas by a different route: see type-safe development.

scripts/emit-types.mjs turns one version's epJSON schema into TypeScript: 858 interfaces for EnergyPlus 26.1, one per object type, plus a TypeMap joining each type name to its interface. Each version's output is its own package, so you install the ones you want and nothing else:

npm install --save-dev @idfkit/types-v26-1

Parameterizing a document with that map is the whole opt-in:

import { loadIdf } from '@idfkit/core/node';
import type { TypeMap } from '@idfkit/types-v26-1';

const doc = await loadIdf<TypeMap>('model.idf');

doc.all('Zone'); // completes among 858 type names
// @ts-expect-error celing_height is a typo for ceiling_height
doc.add('Zone', 'Z1', { celing_height: 3 }); // compile error: typo
// @ts-expect-error the schema's choices are NoSun and SunExposed
doc.add('BuildingSurface:Detailed', 'S1', { sun_exposure: 'Sunny' }); // compile error

The interfaces carry the schema's documentation with them, so units, defaults, and choice lists reach the editor's tooltip rather than a reference tab:

export interface Zone {
  /**
   * X Origin
   * Units: m
   * Default: 0
   */
  x_origin?: number;
  // ...
}

Differs in Python

Static types generated from the schema exists in both libraries and does not behave the same way in Python. The ledger records it as Python partial, JavaScript complete, and what differs is stated here rather than left to be discovered.

Coverage differs, and the difference is visible to anyone not on the newest release. The Python stub set is generated for one EnergyPlus version at a time and is currently shipped for 26.1.0 only, so a model loaded at 9.4 gets the 26.1 field names and choice lists from the editor. The TypeScript type maps are emitted per version and selected by the caller, who parameterises the document with the map for the version being read, so an older model is typed as that older model or is left untyped rather than mistyped.

Application differs too. Python's stubs apply implicitly to every document. TypeScript's are opt-in by construction: an unparameterised document stays untyped, which is what version-generic code needs.

The full entry, including the vocabulary this capability owns, is on the capability parity page.

How this differs from the Python stubs

Both libraries generate types from the epJSON schema, and the parity ledger records the capability as complete in TypeScript and partial in Python. Two things differ.

Coverage. The Python stub set is generated one EnergyPlus version at a time and currently ships for 26.1.0 only, so a model loaded at 9.4 gets 26.1 field names and choice lists in the editor. The TypeScript maps are emitted per version and chosen by the caller, so an older model is typed as that older model or left untyped, never mistyped.

Application. Python's stubs apply implicitly to every document. TypeScript's are opt-in by construction: an unparameterised document stays untyped, which is what version-generic code needs.

The runtime story differs on reads. Both libraries refuse a misspelled field name when you write one: Python raises InvalidFieldError and TypeScript throws "celing_height" is not a field of Zone. Reading is where they part. In Python's strict mode, which is the default, reading zone.celing_height raises as well. In TypeScript a name with no accessor behind it is simply undefined, and nothing distinguishes a typo from a field the file left unset. Parameterizing the document turns that read into a compile error, which is the practical argument for passing the TypeMap even in a codebase that is otherwise loosely typed.

It costs nothing, installed or not

The maps are big. EnergyPlus 26.1 is 2.7 MB of declarations and 9.4 is another 2.5 MB, against a measured 1.32 MB for the whole default install of the idfkit name in JavaScript. That is why they are separate packages rather than a subpath of core: install neither and you have a complete, working library and zero bytes of either map on disk.

Installed, they still cost nothing at run time. TypeMap is a type, not a value, and it is erased at build time. A typed document and an untyped one are the same object graph running the same code, and doc.all('Zone') really is just a string argument. Omit the parameter and everything still works, untyped:

const doc = await loadIdf('model.idf'); // fine, just no completion

Two design details that are easy to get wrong

TypeMap must be emitted as a type alias, never an interface. Interfaces have no implicit index signature, so an interface cannot satisfy Record<string, object> and the map would not fit the AnyTypeMap constraint. This is a real constraint on the generator, not a style preference.

add() and all() deliberately use different helpers. add() takes ValuesOf, which resolves to the exact field interface for a known type name, so TypeScript's excess-property check fires on a misspelled field in an object literal. all() returns ObjectOf, which resolves to the interface for known names and a permissive empty object otherwise, so version-generic code and untyped documents still work. One helper for both would force a choice between catching typos and allowing dynamic field names; two cost nothing and give both.

TypeNameOf has a similar subtlety: its (string & {}) arm is what keeps literal completion alive while still accepting arbitrary strings. Without it, TypeScript widens the parameter to string and the 858 suggestions disappear.

Declarations only, and it is checked

A type package holds one index.d.ts and nothing else: no main, no dependencies, no build step, and no JavaScript. Nothing compiles a declaration file, so there is nowhere for runtime code to be produced the way there was when these were ordinary .ts modules inside @idfkit/core. npm run check:type-packages measures the emitted JavaScript in every type package, both in the working tree and in what npm pack would ship, and fails the build on one byte of it, or on an exported value, which a declaration file will accept and then emit nothing for, leaving an export that crashes whoever imports it.

@idfkit/core is a peer range rather than an exact version. The map borrows exactly one type from core, ExtensibleGroup, and neither package carries runtime, so a skew between the two is a type error at your build and never a failure at run time.

How this is kept honest

Vitest transpiles without typechecking. A change that silently breaks the type map passes npm test cleanly, so the @ts-expect-error assertions in packages/core/tests/typed.test.ts, the ones proving the generated types actually reject bad input, mean nothing unless tsc runs:

npx tsc -p tsconfig.test.json

That is why it is a separate, non-optional step in CONTRIBUTING and a separate job in CI.