Getting started
Install Typespun, generate your first validated loader, and see the exact output of a successful run and a startup failure.
In one short sitting you will declare a configuration as a TypeScript interface, generate a validated loader, run it, and see what a startup failure looks like. The tutorial uses Bun; the generated loader also runs on Node.js 22 and 24.
Want to try it first? Open the playground and edit a declaration with nothing to install.
Prerequisites
- Bun 1.4.1 or newer (or Node.js 22 or newer, adjusting the commands)
- An ESM TypeScript project with a
package.json - A usable
tsconfig.jsonthat includes the schema and generated module
Typespun is pre-1.0. Pin both package versions if you need reproducible builds.
For a minimal project, start with these files:
package.json:
{
"private": true,
"type": "module",
"packageManager": "bun@1.4.1"
}tsconfig.json:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*.ts"]
}Run the remaining commands from the directory containing those files.
1. Install and initialize
Install typespun as an application dependency and typespun-codegen as a
development dependency. The generator package installs an executable named
typespun-codegen, and typespun acts as the wrapper command when runtime is
installed:
bun add typespunbun add --dev typespun-codegenbun typespun init --env-prefix APPThe command scaffolds the project and performs the first generation:
Created src/config.ts.
Created config.yaml.
Created typespun.json.
Added config:generate and config:check scripts.
Generated src/generated/typespun.ts.init creates directories as needed and adds only missing scripts. It does not
run a package installer, create .env, create an application entry point, or
overwrite an existing defaults file or app-owned output file. Both dependencies
are now locally resolvable, so the generated loader is part of this first run.
The initializer accepts:
--style interface|class
--input <path>
--output <path>
--env-prefix <prefix>The defaults are interface style, src/config.ts, and
src/generated/typespun.ts. Here, --env-prefix APP makes the generated
environment key for port equal to APP_PORT.
2. Inspect the scaffold
src/config.ts is a runnable declaration:
/** @typespun */
export interface AppConfig {
port: number;
}typespun.json records the selected paths and prefix. The parser accepts JSON
with comments, so every optional setting stays visible without being enabled:
{
"input": "src/config.ts",
"output": "src/generated/typespun.ts",
"envPrefix": "APP",
// Environment keys use the configured prefix, such as APP_PORT.
// Optional tsconfig override. Default: nearest tsconfig.json to the input.
// "tsconfig": "tsconfig.json",
// Optional defaults override. config.yaml is discovered automatically.
// "defaults": {
// "path": "config.yaml",
// "unknownKeys": "error" // Allowed: "error", "warn", or "ignore".
// },
// Policy for defaults on secret fields. Allowed: "warn", "allow", or "error".
// "secretDefaults": "warn"
}The new config.yaml makes the starter runnable without environment setup:
port: 3000The command also adds these entries without replacing existing scripts:
{
"scripts": {
"config:generate": "typespun generate",
"config:check": "typespun check"
}
}Running init again is safe and leaves an initialized project unchanged.
Flags that conflict with its existing typespun.json are rejected.
3. Regenerate after declaration changes
bun run config:generateExpected output:
Generated src/generated/typespun.ts.The generated module exports Config and loadConfig. Commit this file. Since
init already generated it, running this command before making changes prints
Unchanged src/generated/typespun.ts. instead.
4. Load configuration
Create src/index.ts:
import { loadConfig } from './generated/typespun.js';
const config = loadConfig();
console.log(`Listening on port ${config.port}`);Run the application:
bun src/index.tsExpected output:
Listening on port 3000The first value comes from config.yaml. Environment values such as
APP_PORT=4000 take precedence over that compiled default. Pass envFiles to
load dotenv files; an explicit source wins over dotenv. Arrays in environment
sources use JSON, for example APP_ORIGINS='["https://a.test"]'.
After changing src/config.ts, typespun.json, or a configured defaults file,
rerun bun run config:generate and commit the changed generated module.
5. See a validation failure
loadConfig() throws ConfigError. Handle it at the application boundary:
import { ConfigError } from 'typespun';
import { loadConfig } from './generated/typespun.js';
try {
loadConfig({ source: { APP_PORT: 'not-a-number' } });
} catch (error) {
if (error instanceof ConfigError) {
console.error(error.message);
for (const issue of error.issues) {
console.error(
`${issue.code} ${issue.path} (${issue.envKey ?? 'n/a'}): ${issue.message}`,
);
}
}
}Expected issue summary:
Configuration validation failed
invalid_value port (APP_PORT): Expected a finite numberWhen multiple fields fail, the error aggregates their issues. If a secret
candidate is invalid, its issue never includes the candidate as received or
type details that could reveal an allowed secret.
6. Keep generation current in CI
Commit src/generated/typespun.ts, then add:
bun run config:checkThe command exits 0 when output is current and 1 when it is stale or schema
analysis fails. Unusable CLI arguments or project/TypeScript configuration exit
2.
Troubleshooting
The CLI command is not found
From the project root, use the installed runtime wrapper:
bun typespun --helpIf you want to run the generator directly without the runtime package, use:
bunx --package typespun-codegen typespun-codegen --helpNo schema is found
Set input explicitly or keep exactly one of src/config.ts,
src/config.mts, and src/config.cts.
Multiple defaults files are found
Set defaults.path. Conventional discovery rejects ambiguity instead of
guessing.
Environment values fail validation
Numbers must be complete finite numeric strings; booleans are case-insensitive
true or false; arrays must be JSON arrays with a uniform supported element
type. Typed overrides are validated but never string-coerced.
Running TypeScript directly on Node.js fails to find the loader
Generated modules import the declaration with a .js specifier, which is the
correct NodeNext convention for TypeScript that is compiled before it runs.
Node.js's built-in type stripping does not rewrite specifiers, so running a
.ts entry point directly cannot resolve it:
$ node src/index.ts
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/generated/typespun.js'Two things work. Compile first, which is what CI exercises on Node.js 22, 24 and 26:
tsc -p tsconfig.json
node dist/index.jsOr use a runtime that resolves TypeScript specifiers itself, such as Bun:
bun src/index.tsDecorated class declarations are a separate case: Node.js type stripping
rejects decorator syntax outright, so the --style class form needs Bun or a
TypeScript-aware loader regardless of how specifiers resolve.
A generated import has the wrong extension
Make sure tsconfig.json uses the intended module and module-resolution mode,
then regenerate. Typespun derives .js, .mjs, or .cjs specifiers from the
input extension and compiler options.
Next
Source precedence
The five source kinds, resolved per leaf, highest defined value wins.
Generated code
Why the loader is committed, how determinism works, and what check does in CI.
CLI reference
init, generate and check — flags, output and exit codes.
Runnable examples
The interface and class examples this tutorial mirrors, exercised by CI.