TypeScript
Kubb is built in TypeScript end to end, and that choice shapes how you work with it. The point is not that the source happens to be typed. It is that types travel with you: from the config you write, through the plugins and adapters that run, down to the AST nodes a generator visits. When the compiler already knows the shape of everything in the pipeline, IntelliSense can lead you through each choice and most mistakes surface before generation ever runs.
Every public surface takes a generic that pins down the same four things: the user-facing options, the resolved options after defaults, the plugin name, and the resolver shape. That generic threads through defineConfig, definePlugin, defineParser, createAdapter, defineGenerator, and the AST factories. Declare it once and the rest of the surface follows from it.
Inference starts at the config
You rarely write a type annotation. The kubb.config.ts entry point is where inference begins, and everything downstream reads from it. Pass a plugin to defineConfig and its options are checked against that plugin's own Options type, so a typo in pluginTs is a compiler error, not a silent no-op at runtime.
import { } from 'kubb'
import { } from '@kubb/plugin-ts'
export default ({
: { : './petStore.yaml' },
: { : './src/gen', : true },
: [
({
: { : 'models' },
// Hover any option to see the inferred type.
}),
],
})Hovering an option shows the type the compiler resolved for it. That feedback loop is the whole idea: the config is the source of truth, and the editor reflects back what Kubb will actually do with it.
Why strict mode matters
Kubb assumes TypeScript strict mode, and a few of its APIs only behave the way you expect with it on. All exported types compile cleanly under "strict": true. The AST node guards and resolvers in particular rely on strictNullChecks, because that is what lets the compiler treat a possibly-absent value as possibly-absent and narrow it once you check.
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022"
}
}IMPORTANT
If you cannot enable full strict, enable at least strictNullChecks. Without it, RefSchemaNode.ref and resolver helpers return widened types and you cast manually.
How a plugin's types thread through the pipeline
A plugin is described by a single generic, PluginFactoryOptions. It is the contract between the options a user passes and the values a plugin works with at runtime, and it carries four pieces of information through the plugin lifecycle:
| Generic | Purpose |
|---|---|
TName | The plugin's name literal (e.g. 'plugin-ts'). Used by dependencies lookups. |
TOptions | The user-facing options accepted by the factory. |
TResolvedOptions | The shape of options after defaults are applied (what runs at runtime). |
TResolver | The plugin's Resolver extension (pluginName, naming helpers). |
The split between TOptions and TResolvedOptions is the part worth understanding. TOptions is what a user is allowed to leave out, and TResolvedOptions is what the plugin sees once defaults are filled in. Declare the alias once and both halves stay in sync across the factory and its hooks.
import { } from '@kubb/core'
import type { , } from '@kubb/core'
type = { ?: string }
type = <>
type = <'plugin-example', , , >
export const = <>(() => {
const : = { : . ?? '.ts' }
return {
: 'plugin-example',
: ,
: {
'kubb:plugin:end'({ }) {
// `files` is FileNode[]; no cast required.
.(`${.} files emitted with suffix ${.}`)
},
},
}
})TIP
Inside hooks, ctx.options is typed as TResolvedOptions and ctx.config is the fully resolved Config. No casts required.
Adapters extend the same idea
Adapters follow the same pattern with AdapterFactoryOptions, with one extra slot. Alongside TName, TOptions, and TResolvedOptions, an adapter also pins TDocument, the shape of the parsed spec it produces. That is the type every downstream plugin reads from, so getting it right here is what keeps the rest of the pipeline honest.
import { , } from '@kubb/core'
import type { } from '@kubb/core'
type = { ?: boolean }
type = <>
type = { : <string, unknown> }
type = <'adapter-example', , , >
export const = <>(() => ({
: 'adapter-example',
: { : . ?? false },
: null,
async () {
return ..()
},
() {
return []
},
async () {
// Throw or call ctx.error here when the spec is invalid.
},
}))The same alias flows into Adapter<AdapterExample>, so consumers that import the adapter type get the options and document for free, without redeclaring anything.
Parsers carry their own metadata
Parsers see the files a plugin produced, and each FileNode<TMeta> keeps the metadata its plugin attached. The TMeta generic is how that survives the trip: type the parse parameter and file.meta comes back as your own shape rather than unknown.
import { , } from '@kubb/core'
type = { : 'ts' | 'tsx' }
export const = ({
: 'parser-typed',
: ['.ts'],
(: .<>) {
const = . // typed as Meta
return `// ${?. ?? 'unknown'}\n`
},
() {
return ''
},
})How the AST narrows
The AST is a set of discriminated unions, which is what makes it safe to walk. The SchemaNode union shares one kind: 'Schema' discriminator and uses node.type to tell variants apart, so once you check a node's type, the compiler knows exactly which fields exist. Two helpers cover the cases the discriminants alone do not: narrowSchema narrows a SchemaNode to a specific variant, and isHttpOperationNode narrows an OperationNode to an HttpOperationNode.
import { } from '@kubb/core'
declare const : .
const = .(, 'ref')
if (?.) {
const : string = .
.()
}
declare const : .
if (.()) {
// op is now HttpOperationNode. method and path are non-nullable
const : . = .
}These are the only two guards @kubb/ast exports. Everything else narrows through the kind and type discriminants directly.
See also
- Plugins:
definePlugin,PluginFactoryOptions, resolvers, generators. - Adapters:
createAdapterandAdapterFactoryOptions. - AST: node types, visitors, guards.
- Configuration: top-level
defineConfigshape.