Types
henri is JavaScript and stays JavaScript: there is no build step, no .ts file
in an application and no compiler between you and the framework. It does ship
hand-written type declarations, so an editor — and a coding agent reading the
same signatures — knows what res.render() takes, what req.pagination()
answers and which keys config/default.json accepts.
| Package | Declares |
|---|---|
@usehenri/core |
The henri global, the request and response helpers, the controller, model and routes files, the whole configuration. |
@usehenri/react |
withHenri, useHenri, request, RequestError, the form components and the engine’s build(). |
@usehenri/inertia |
useHenri, Form, pathFor, getRoute, request, resolvePage, henriViteConfig(). Link and Head come from Inertia. |
@usehenri/testing |
setup, teardown, request, agent, henri. |
What an editor needs
Section titled “What an editor needs”Nothing, in an application scaffolded by henri new: the jsconfig.json it
writes at the root of the project already says where to look.
{ "compilerOptions": { "allowJs": true, "checkJs": false, "module": "node16", "moduleResolution": "node16", "skipLibCheck": true, "target": "es2023", "types": ["@usehenri/core"] }, "exclude": ["node_modules", "app/views", ".henri"]}types: ["@usehenri/core"] is the load-bearing line. It is what makes the
henri global known everywhere without requiring anything, and it is all an
older application needs to add to catch up. app/views is excluded because the
pages have a jsconfig.json of their own (Next.js and Vite each want theirs).
checkJs is off, so an editor offers completion and documentation without
turning a file red. Turn it on when you want the annotations below actually
checked; the models are globals whose names henri only knows at runtime, so
declare the ones you use in a .d.ts of your own when you do:
declare const Task: any;declare const User: any;Annotating a file
Section titled “Annotating a file”A controller, a routes file and a model file are plain objects: nothing tells
an editor what they are. One JSDoc line does, and henri new and
henri generate write it for you.
/** @type {import('@usehenri/core').Controller} */module.exports = { before: { 'show,edit': loadTask },
index: async (req, res) => { const { page, perPage, skip, limit } = req.pagination(); const tasks = await Task.find().skip(skip).limit(limit);
return res.collection(tasks, { page, perPage, total: await Task.countDocuments(), }); },};req and res are typed from that annotation alone: req.permit(),
req.flash(), req.id, req.can(), req.authorize(), req.scope(),
res.render(), res.boom.*, res.resource(), res.collection(),
res.negotiate() and everything Express already had.
/** @type {import('@usehenri/core').RoutesFile} */module.exports = { root: 'main#home', 'resources tasks': { only: ['index', 'show'], member: { 'post archive': 'archive' }, },};The keys are checked as far as a type can check them: root, a path, a verb
and a path, resources, crud and namespace. 'gett /tasks' is a type
error, and so is only: ['list'].
/** @type {import('@usehenri/core').ModelFile} */module.exports = { options: { timestamps: true }, schema: { title: { type: 'string', required: true }, status: { type: 'string', enum: ['todo', 'done'], default: 'todo' }, }, store: 'default',};The eleven field types are checked; every other key of a field is passed to the adapter, so the shape stays open (see Models).
/** @type {import('@usehenri/core').Policy} */module.exports = { index: (user) => Boolean(user), show: (user, task) => String(task.userId) === String(user.id), scope: (user) => ({ userId: user && user.id }),};The seven actions of a resource are declared, so user, the record and the
context are typed inside them, and any other action of the controller takes
the same shape. req.can(), req.authorize() and req.scope() come with
the Controller annotation above; authorize() resolves with the record it
was given, so it keeps its type. See Policies.
/** @type {import('@usehenri/core').JobDefinition} */module.exports = { queue: 'mailers', maxAttempts: 5, timeout: '30s',
perform: async (args, { henri, job, signal }) => { henri.pen.info('welcome', job.id, job.attempt); },};context is typed from that annotation, and so is what henri.jobs answers:
perform(), performIn() and performAt() resolve with a Job, stats()
with a JobStats, and henri.jobs.dead with the same. See
Jobs.
Configuration is the shape of config/default.json, and is worth an
annotation when a helper builds part of it:
/** @type {import('@usehenri/core').Configuration} */const config = { renderer: 'inertia', stores: { default: { adapter: 'drizzle', dialect: 'sqlite', url: 'file:.henri/app.db', }, },};It cannot drift from what henri actually accepts: the declarations and the
schema the boot runs are compared key by key by
@usehenri/core’s own suite, along with the table of the
configuration page. Adding a key means adding it in
all three.
What is not typed
Section titled “What is not typed”- The models.
Taskis a Drizzle model class, a MongooseModelor a SequelizeModelStaticdepending on the store, and its fields come from your schema. henri does not pretend otherwise: the globals areanyunless you declare them. What every adapter adds is documented onPage—paginate()answers the same{ records, page, perPage, total, pages }everywhere. req.user. A model instance, same reason.henri.user.publicUser(user)answers a typedPublicUser.henri.config.get(key). The value is whatever the JSON holds; pass the type you expect (henri.config.get<string>('secret')).- The adapters and the view engines are typed as contracts
(
StoreAdapter,ViewEngine), not as the ORMs behind them.
In TypeScript
Section titled “In TypeScript”Nothing stops an application from being written in TypeScript — henri never
loads a .ts file itself, so it has to be compiled to CommonJS first, and the
declarations are the same ones. The framework is not tested that way; a
JavaScript application with checkJs is the supported path.
Checking them
Section titled “Checking them”The declarations live next to the code they describe
(packages/core/index.d.ts and one file per package) and are checked in CI by
pnpm test:types, which verifies that every declaration is shipped by npm and
then runs tsc --noEmit over types/ in the repository. Those fixtures call
the API both correctly and — on the lines marked @ts-expect-error —
incorrectly, so a declaration that stops catching a mistake fails the build.
