Skip to content

Types without a model

Nola can derive runtime values from your exported TypeScript types. These values provide toJsonSchema(), validate(input), parse(input) and a ~standard object for Standard Schema and Standard JSON Schema. See Types as values for the API.

You can use these features in a plain TypeScript project without a model, provider key or nola.config.ts. This guide shows the setup, including how to run, type-check and build it.

Install nola-lang as a devDependency and @nola-lang/runtime as a dependency. This setup does not use @nola-lang/providers.

{
"type": "module",
"scripts": {
"start": "node --import nola-lang/register src/main.ts",
"check": "nola check"
},
"dependencies": {
"@nola-lang/runtime": "^0.1.14"
},
"devDependencies": {
"nola-lang": "^0.1.14",
"typescript": "^5.6.0"
}
}

Use a directory-style include, such as ["src"], in tsconfig.json.

Keep your types in ordinary .ts files. To use them as values, import the module through its view: the same path with a .tsi extension. When no models.tsi exists on disk, Nola resolves ./models.tsi to a view of models.ts with values for its exported type aliases and interfaces:

// src/models.ts — plain TypeScript, untouched
export interface Address {
street: string;
city: string;
zip?: string;
}
export interface User {
name: string;
/** @format email */
email: string;
role: "admin" | "member";
address: Address;
/** @minItems 1 */
tags: string[];
dob: Date;
}
export type Event =
| { kind: "refund"; amount: number }
| { kind: "chargeback"; reason: string };
export function greet(u: User): string {
return `hi ${u.name}`;
}
// src/main.ts — plain TypeScript
import { User, Event, greet } from "./models.tsi"; // the view of models.ts
const schema = User.toJsonSchema(); // draft 2020-12
const checked = User.validate(body); // { ok: true, value } | { ok: false, issues }
if (!checked.ok) {
for (const issue of checked.issues) console.warn(issue.path.join("."), issue.message);
}
const user = User.parse(body); // User, or throws NolaValidationError
user.dob instanceof Date; // true — the ISO string was revived
greet(user); // functions come through the view unchanged
Event["~standard"].jsonSchema.input({ target: "openapi-3.0" });

The view re-exports functions, constants and classes, so you can import greet from the same path. Use import type { User } when you need only the TypeScript type.

Run the entry with Nola’s loader to resolve the view:

Terminal window
node --import nola-lang/register src/main.ts

Pass your input as body. For example, a body with an invalid role and a number among its tags produces these validation issues:

{"ok":false,"issues":[
{"path":["role"],"message":"expected one of \"admin\", \"member\", got \"x\""},
{"path":["tags",1],"message":"expected string, got number"}
]}

The loader works without nola.config.ts for this use case. A config becomes necessary when your code asks a model.

Use nola check, or generate declarations for plain tsc.

nola check type-checks the project, including plain .ts files, and resolves ./models.tsi directly:

Terminal window
npx nola-lang check

Plain tsc needs a declaration file to resolve the .tsi import. Generate the declarations and enable allowArbitraryExtensions:

Terminal window
npx nola-lang declarations # writes src/models.d.tsi.ts
tsconfig.json
{
"compilerOptions": {
"allowArbitraryExtensions": true
},
"include": ["src"]
}

Add *.d.tsi.ts to .gitignore. Rerun nola declarations when a viewed module’s exports change; the bundler plugins below do this during the build. TypeScript can then resolve the import and sees the User value as InferType<User>.

With the VS Code extension, the editor resolves ./models.tsi directly. Hover shows InferType<User>, and go-to-definition opens the interface in models.ts.

In other TypeScript-aware editors, generate declarations with nola declarations so the editor can resolve the import through the adjacent declaration file.

A Nola bundler plugin can resolve views while building the application. The resulting JavaScript runs under Node with @nola-lang/runtime as its only Nola runtime dependency. For example, with Vite:

vite.config.ts
import { defineConfig } from "vite";
import nola from "@nola-lang/vite";
export default defineConfig({
plugins: [nola()],
build: { ssr: "src/main.ts", target: "node22" },
});

The same view support is available in @nola-lang/esbuild, @nola-lang/webpack, @nola-lang/rollup, @nola-lang/rolldown, @nola-lang/rspack and @nola-lang/next in webpack mode. Turbopack does not resolve a hand-written ./models.tsi import from a .ts file; see Bundlers and Turbopack.

Without a bundler, run npx nola-lang build --out dist to emit dist/src/models.tsi.js and its declaration file. Your JavaScript entry must import that built path. If a tsc-compiled main.js still imports ./models.tsi, plain Node cannot resolve it; use the loader, a bundler or the built .tsi.js path. See Deploying.

  • validate collects validation issues, revives Date fields and rejects unknown properties. parse throws NOLA3016 with the same issues when validation fails.
  • JSDoc tags such as @format email, @minimum 1 or @minItems 1 become schema keywords and are enforced (Constraints).
  • toJsonSchema() is draft 2020-12; ~standard.jsonSchema also serves draft-07 and openapi-3.0 (Standard Schema).
  • Nola derives the resolved type, including unions, Partial/Pick/Omit, extends, Record, tuples and types imported from packages. Map, Set, Promise, functions and generics used without arguments are unsupported; nola check reports them at the type. See Schema derivation.
  • Use distinct basenames for source modules. If models.ts and models.tsi both exist, the real .tsi file takes precedence over the view, and Nola warns about the collision.

Add a nola.config.ts with a model, then write a .tsi file with an extractor. You can reuse User as its result type, so validation and inference continue to use the same type definition. See Add Nola to an existing project.