Skip to content

TypeScript interop

You can use .tsi and .ts files in the same project. The import extension depends on which kind of file you are importing.

Plain TypeScript imports a .tsi module with the literal extension:

right.ts
import { extractPerson } from "./person.tsi"; // RIGHT
// not-checked
import { extractPerson } from "./person"; // WRONG — unresolved
import { extractPerson } from "./person.js"; // WRONG — no such file
person.tsi
export interface Person { name: string; age: number }
export infer function extractPerson(.text: string) {
return ask `the person described in the text`: Person;
}

Different tools resolve the types in different ways:

  • The editor — the VS Code extension’s tsserver plugin gives .ts files full types for .tsi imports and go-to-definition onto the infer function (Editor setup).
  • nola check — lowers the .tsi files and type-checks them together with the project’s .ts files; diagnostics are mapped back to .tsi positions. Plain tsc over src is not a supported check path — it cannot parse .tsi.
  • Built output — nola build emits <name>.tsi.js + <name>.tsi.d.ts pairs, so consumers of the built package get types normally.
  • Plain tsc or framework builds that must resolve .tsi imports (for example next build, or tsc --noEmit in CI): set "allowArbitraryExtensions": true and run nola declarations (or let a bundler plugin do it) to write adjacent <name>.d.tsi.ts files. Gitignore them — the editor hides them next to a live .tsi, and nola check ignores them.

Use the standard NodeNext .js specifier — ./helpers.js finds the on-disk helpers.ts:

tickets.ts
export async function createTicket(title: string, priority: number): Promise<string> {
return `${title}:${priority}`;
}
file-ticket.tsi
import { createTicket } from "./tickets.js"; // RIGHT — NodeNext specifier, file on disk is tickets.ts
export infer function fileTicket(.request: string) {
return ask createTicket(`a short ticket title`: string, 2);
}
// not-checked
import { createTicket } from "./tickets.ts"; // WRONG — TS5097
import { createTicket } from "./tickets"; // WRONG — TS2835

nola check and the editor resolve the .js specifier to the TypeScript source. At runtime, the loader tries x.ts when a relative ./x.js file is missing; an existing .js file takes precedence.

A literal ./helpers.ts import can run with Node’s native type stripping, but requires allowImportingTsExtensions for type checking. It also keeps the .ts extension in built output, where that file may no longer exist. Use .js for code you plan to build.

Types imported from another file are carried into the extractor schema automatically:

models.ts
export interface Person {
name: string;
age: number;
}
report.tsi
import type { Person } from "./models.js";
export infer function extractPerson(.text: string) {
return ask `the person described in the text`: Person;
}

The compiler gets the runtime schema from ./models.tsi, the view of models.ts. You can use that import from plain TypeScript too: import { Person } from "./models.tsi" provides both the type and its InferType value.

If neither a Nola file nor a TypeScript module exists for the import, Nola raises NOLA2007. If both models.ts and models.tsi exist, the import selects the Nola file and the build warns. Prefer distinct basenames.

// not-checked
import type { Person } from "./models.js"; // RIGHT — the type, for a schema in a .tsi
import { Person } from "./models.tsi"; // RIGHT — the type AND its InferType value
import { Person } from "./models.js"; // WRONG — kept at run time as written; models.js exports no value named Person

The type keyword is not optional for a type-only import from a plain module: like Node’s own handling of .ts files, nola run strips types without rewriting imports, so a bare import { Person } stays a value import and fails when the module loads.

tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowArbitraryExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

Use a directory in include, such as ["src"], so the editor can include .tsi files. A glob limited to .ts excludes them. NodeNext explains the .js import specifiers above, and allowArbitraryExtensions lets TypeScript resolve the .tsi declarations and view declarations generated by Nola.

Next: Restrictions and reserved syntax