Skip to content

feat(cli): generate schema-derived types with types generate --future-schema - #719

Draft
maoberlehner wants to merge 35 commits into
mainfrom
feat/DX-525-types-future-schema
Draft

feat(cli): generate schema-derived types with types generate --future-schema#719
maoberlehner wants to merge 35 commits into
mainfrom
feat/DX-525-types-future-schema

Conversation

@maoberlehner

@maoberlehner maoberlehner commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Adds storyblok types generate --future-schema, which generates accurate TypeScript types for a UI-managed Storyblok space, and deprecates the legacy generator.

Why

The legacy generator builds types with json-schema-to-typescript from pulled component JSON, and gets them wrong in ways that matter: field optionality does not follow required, bloks fields are not narrowed by component_whitelist, the nestable and root distinction is ignored, and custom field types fall back to a loose parser hook. It also requires a prior components pull with matching flags, which is a persistent footgun.

@storyblok/schema already models all of this correctly at the type level, but only for people who define their schema in code. This flag gives UI-managed spaces the same types without adopting a code-driven schema workflow.

Approach

The command fetches the space's components and emits block definition type literals plus the public surface of a hand-written schema.ts. TypeScript then resolves content shapes in the user's own project through BlockContent, so the CLI duplicates none of the field-to-value rules and cannot drift from them.

import type { Block, Schema } from './.storyblok/types/295018/storyblok-schema';

interface Props { block: Block<'hero'> }

const client = createApiClient({ accessToken }).withTypes<Schema>();

Block<'hero'> is the user-facing surface, matching the helper dogfooded in the Astro playground in 2852ad1. The definition types are plumbing for withTypes<Schema>(), BlockContent, and the story aliases.

Emitted exports: Block<TName>, AnyBlock, Schema, Blocks, FieldPlugins, Story, StoryMapi, and one <Name>BlockDefinition per block. --type-prefix and --type-suffix apply to every one of them.

adr/0012-schema-derived-type-generation.md records the decision and, importantly, the rejected alternative: resolving flattened content interfaces with the TypeScript compiler API. That was prototyped and rejected because self-referencing blocks collapse to any under every NodeBuilderFlags combination, Prettify destroys aliasSymbol so named types inline as raw structure, the property walk would duplicate the field-to-value rules in JS, and it would add typescript as a CLI runtime dependency with a platform-specific native binary. The ADR exists so nobody retries it.

Deprecation

Running types generate without --future-schema now prints a deprecation warning naming the replacement and the concrete defects. Nothing is removed and no timeline is committed. The legacy path is otherwise byte-identical, and by default the new output goes to a separate file (storyblok-schema.d.ts), so the two generators do not collide unless --filename forces them to, which warns.

--strict, --custom-fields-parser, --compiler-options, and --suffix are legacy-only and error when combined with --future-schema, rather than being silently ignored. A value that came from a config file rather than the command line is reported as ignored instead, so a project that configures strict for the legacy generator can still use this flag.

Custom field types

Custom fields need their field_type bound to a validator with defineFieldPlugin. The CLI looks for schema/schema.ts under the CLI base path (--path, default .storyblok) by convention, or an explicit --field-plugins <path>, and accepts either a schema export (a defineSchema result) or a bare fieldPlugins record. Unregistered field_types fall back to an untyped value and are reported as a warning.

Documentation

The user-facing documentation lives in a docs PR, storyblok/storyblok-docs-platform#597, not in this repo. The package README only gains the deprecation callout and the new flags in its existing options table.

Notes for the reviewer

  1. One unrelated lockfile line. Adding @storyblok/schema as a packages/cli devDependency required pnpm install, which also dropped async-sema 3.1.1 from the @storyblok/management-api-client snapshot. That is stale drift from ff3642d: mapi-client no longer declares that dependency, only packages/cli does. It is a correction, not an accidental removal, and splitting it out would just mean the next pnpm install regenerates it.

  2. Land fix(schema): close type gaps surfaced by the kitchen sink demo #718 first, and resolve one overlap by hand. The branches overlap in packages/cli/src/commands/schema/init/generate-code.ts and pnpm-lock.yaml. Both fix the same schema init defect — an inert whitelist beside restrict_components: false was mapped to allow, so the next schema push switched a disabled restriction back on — but fix(schema): close type gaps surfaced by the kitchen sink demo #718 fixes it in the private toDslField inside generate-code.ts while this branch fixes it in to-dsl-field.ts, the shared module it extracted that function into. Taking fix(schema): close type gaps surfaced by the kitchen sink demo #718's hunk would reintroduce the bug; dropping it is correct because the extracted module already carries the gate. to-dsl-field.test.ts covers both whitelist forms, so a wrong resolution fails the tests.

  3. Defects found and fixed during review.

    allow emission, all three of which produce types that reject content the editor accepts:

    • It was emitted for a field whose restrict_components is false. The group-whitelist form of that state is never normalized by the backend, so it is live rather than theoretical.
    • It was emitted for any field type, including multilink, where the same wire key holds content type names rather than block names. The gate is bloks plus richtext — a richtext whitelist genuinely does restrict blocks.
    • Entries naming a deleted component were kept. ApplyAllow is an Extract, so a list of only stale names resolved the field to never[].

    Messages that told the user something untrue:

    • The --filename collision warning fired for every --filename, asserting a clash with a file nothing writes. It now compares against the legacy default.
    • The unmapped-field_type warning told users to place a module where one already sat — the case schema init produces, since it writes a schema export with no fieldPlugins key. Missing and unusable modules are now distinguished, and the warning names the export to rename.
    • All four legacy-only flags shared one rationale about field optionality and the JSON-schema compiler, none of which applies to --suffix. Each now carries its own reason.

    Also fixed:

    • tab and section fields surfaced as key?: null on content types. They carry no value, so those keys are dropped — in the codegen template rather than this command's serializer, so hand-written defineSchema blocks benefit too.
    • Emitted blocks are sorted by name, so regeneration is byte-stable: MAPI does not promise an order and this file is committed.
    • The field-plugins convention path honours --path; the --field-plugins error names a near-miss export, since the real failure mode is a misnamed export rather than a missing file; and the shared jiti bootstrap and the .storyblok/schema literal are each defined once.

Fixes DX-525

Comment thread packages/cli/src/commands/types/generate/index.ts Outdated
Comment thread packages/cli/src/commands/types/generate/index.ts Outdated
Comment thread packages/cli/src/commands/types/generate/README.md Outdated
@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@storyblok/angular

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/angular@719

@storyblok/astro

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/astro@719

@storyblok/api-client

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/api-client@719

storyblok

npm i https://pkg.pr.new/storyblok/monoblok/storyblok@719

@storyblok/experiments

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/experiments@719

@storyblok/js

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/js@719

storyblok-js-client

npm i https://pkg.pr.new/storyblok/monoblok/storyblok-js-client@719

@storyblok/lint-config

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/lint-config@719

@storyblok/live-preview

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/live-preview@719

@storyblok/management-api-client

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/management-api-client@719

@storyblok/migrations

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/migrations@719

@storyblok/nuxt

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/nuxt@719

@storyblok/react

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/react@719

@storyblok/region-helper

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/region-helper@719

@storyblok/richtext

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/richtext@719

@storyblok/schema

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/schema@719

@storyblok/svelte

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/svelte@719

@storyblok/vue

npm i https://pkg.pr.new/storyblok/monoblok/@storyblok/vue@719

commit: 93d7812

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

⚠️ Cross-package changes detected

This PR is titled as a feat(cli) commit but modifies files across 5 packages:

  • packages/capi-client/
  • packages/cli/
  • packages/live-preview/
  • packages/mapi-client/
  • packages/schema/

When this PR is squash-merged, all changes become a single feat(cli) commit.
Nx release uses file changes (not the commit scope) to determine which packages to bump,
so every package listed above will get a version bump on the next release.

If the changes to other packages are cosmetic (README fixes, dependency bumps, config cleanup),
please split them into a separate PR with a chore: title. chore commits don't trigger version bumps.

Implements serializeBlockDefinition and serializeField to convert
MAPI components into TypeScript type literals for block definitions.
Widens id/created_at/updated_at (not type-relevant) and keeps literal
types for name/is_root/is_nestable/folder/fields (read by type machinery).
Collects custom field_type values for unmapped plugin warnings.

Fixes DX-525
Replace 'as Record<string, Record<string, unknown>>' with a named type
predicate function isFieldRecordMap to avoid type casts and clarify intent.
Preserves runtime behavior and test coverage.

Also narrow test title to match what is asserted (tab fields only).

Fixes DX-525
Implement the single-file render module that assembles component definition
type literals into a complete .d.ts file with shared surface types (Block,
Blocks, Schema, FieldPlugins, etc.). All emitted names are resolved through
buildNames to ensure prefix/suffix renaming is consistent everywhere.
Add renderSeparateFiles() to generate separate .d.ts files per block
definition plus a main file holding the shared surface. Block definitions
never reference each other, so no cross-imports between block files are
needed. Import from componentFileName and resolveFileNames utilities to
kebab-case names and dedupe collisions. Reuse renderFieldPlugins and
renderSurface from renderSchemaTypes.

Fixes DX-525
…ning for types generate

Address review follow-ups on the --future-schema branch: migrate its
user-facing output to getUI() per packages/cli/CLAUDE.md (raw
konsola/Spinner calls are only allowed in unmigrated command code),
compute outputDir with resolvePath() so it matches the "absolute
directory" contract documented on generateSchemaTypes, warn when
--field-plugins is passed without --future-schema instead of silently
ignoring it, and import saveToFile directly from utils/filesystem
instead of widening the utils barrel (avoids a second import route to
the same function, which the barrel change made a mocking trap).

Also adds a command-level test for the --future-schema success path
(install hint, per-file output, unmapped-field-type warning), mocking
getUI() and schema-types the way src/lib/config/helpers.test.ts does.
Adds a type-level test proving definition types plus @storyblok/schema's
BlockContent reproduce hand-written schema types: name-keyed Block<T>
resolution, required/optional fields, allow-list narrowing, recursive
blocks, and is_nestable exclusion from bloks unions. A drift test
(toMatchFileSnapshot) keeps the committed fixture in sync with what
renderSchemaTypes actually emits, so the type-level assertions never run
against stale output.

Adds @storyblok/schema as a devDependency of packages/cli (the fixture
imports it; the CLI itself never does) and scopes vitest's typecheck
block to *.test-d.ts so it does not run tsc over the whole suite. The
fixture is excluded from eslint --fix, which would otherwise reformat it
out of sync with the renderer's literal output.
renderSchemaTypes/renderSeparateFiles unconditionally imported
Schema as InferSchema from @storyblok/schema even when no field-plugins
declaration used it, leaving a dead import in every --field-plugins-less
generation. Import it only when the field-plugins branch needs it.

Also fixes the AnyBlock type-level test: toHaveProperty('component')
passes for any single-block type too, so it didn't prove "accepts any
block". Assert the full component discriminant union instead. Drops a
structurally vacuous not.toEqualTypeOf('page') check superseded by the
exact-union assertion on the same line, and retitles/tightens the tab-field
test to assert the exact resulting type.
…ent it, and record the ADR

Proves the group-to-folder and whitelist-to-allow resolution end to end
against a mocked MAPI, since no unit test exercises the join between
fetched component folders and fetched components. Documents
--future-schema in the types generate README, moves the legacy
generator's docs under a deprecated heading, and adds
adr/0012-schema-derived-type-generation.md recording the decision and
the rejected TypeScript-compiler alternative.

Fixes DX-525
The convention path (.storyblok/schema/schema.ts) degrades silently to
no custom field types both when the module is missing and when it
exists but exports neither accepted shape, e.g. a typo'd export name.
The README previously only described the missing-module case,
which could send a user hunting for the wrong problem.

Fixes DX-525
…suffix under --future-schema

Final review pass on the types generate --future-schema feature:

- render.ts emitted `Story = InferStory<Blocks>` and `StoryMapi =
  InferStoryMapi<Blocks>`, both missing the `TFieldPlugins` second argument
  that `Block<TName>` already threads. Registered custom fields silently fell
  back to an untyped value through Story/StoryMapi.
- Add a field-plugins fixture (hand-rolled Standard Schema, no new
  dependency) and a type-level assertion that only passes when FieldPlugins
  reaches Story, verified empirically to fail when the fix is reverted.
- Assert the real withTypes<Schema>() constraint (Blocks extends Block), not
  just its shape.
- --suffix is legacy-only and meaningless under --future-schema; reject it
  like the other legacy flags instead of silently ignoring it.
- README: document --path as a supported --future-schema option.
- Move the legacy deprecation warning after the command banner.
- Record the MAPI per-space name-uniqueness assumption behind
  definitionByComponent.
- eslint.config.mjs: ignore the new plugins fixture, matching the existing
  byte-identical-fixture exemption.

Fixes DX-525
Move the --future-schema action body into future-schema.ts so the command
action stays a thin branch, shorten the option description, and keep the
README to updates of what was already there. The full --future-schema
documentation moves to the docs platform.

Fixes DX-525
A field can carry a whitelist with restrict_components set to false. The
app treats that as unrestricted, so narrowing the emitted type rejects
content the editor accepts. Storyblok strips stale name lists when the
flag is false but never strips component_group_whitelist, so the group
case is a live, persistable state.

Emit allow only when the restriction is active, and only for bloks and
richtext, the two field types whose whitelist names blocks. On a
multilink the same key holds content type names, so emitting it put a
misleading list in a file users read.

toDslField gets the same gate, which also fixes schema init: mapping an
inert whitelist to allow made the next push re-derive
restrict_components: true and switch a disabled restriction back on.

Fixes DX-525
MAPI does not promise a stable component order, and the generated file is
committed, so an upstream reordering showed up as a diff with no semantic
change. Sort by name, comparing code units rather than using
localeCompare, whose result depends on the machine's locale.

An allow entry naming a component that no longer exists matches nothing
through ApplyAllow's Extract, and a list of only such names resolves the
field to never[], rejecting every value. Storyblok's cleanup job is
eventual, skips non-nestable components, and never runs for schemas
imported through the API, so stale names do arrive here.

Fixes DX-525
…ename

A project whose config sets `strict` for the legacy generator could not
use --future-schema at all: assertNoLegacyFlags only checked whether a
value was present, and applyConfigToCommander hydrates config values as
if they had been typed. Commander records the source, so config-sourced
flags are now reported as ignored instead of erroring, and a flag the
user actually typed still fails.

Both generators write <path>/types/<space>/<filename>.d.ts, so an
explicit --filename makes them overwrite each other. Warn rather than
refuse: the collision only matters if both are run.

Fixes DX-525
Two call sites constructed jiti identically. Extract importModule, kept
thin: the callers disagree on error wrapping and path resolution, so
folding either in would impose one caller's behaviour on the other.

The .storyblok/schema literal appeared in the schema init default, the
field-plugins lookup, and two help strings. Centralize it, and derive the
convention path from --path so field-plugin discovery uses the same base
as the generated output. Its comment claimed it matched what schema init
writes, which was not quite true: schema init has its own --out-dir that
also ignores --path, so the two agreed only by coincidence.

Also name a near-miss export in the --field-plugins error, since the
failure mode is a misnamed export rather than a missing one.

Fixes DX-525
…ditions

The display-path walker was a copy of the slugified one; parameterize the
segment transform instead. Loosen the cycle test, which pinned an
iteration-order artifact rather than behaviour, and replace its `as never`
fixtures with the typed helper already in the file.

renderSchemaImport and renderFieldPlugins decided independently whether
the file uses a user field-plugins module, so a disagreement would emit an
unused InferSchema import. Derive both from one predicate.

Fixes DX-525
Storyblok accepts component names like `2_col`, whose PascalCase form is
not a valid identifier. An emitted `export type 2ColBlockDefinition` is a
syntax error that takes the whole declaration file with it, not just its
own line, so every type in the file dies.

Extract toSafeIdentifier and apply it to the finished base name, after any
fixed suffix: resolveVarNames' numeric disambiguation cannot reintroduce a
leading digit, but toPascalCase can.

Fixes DX-525
Adds --future-schema, --field-plugins, and --type-suffix to the options
table, clarifies --filename and --suffix, and describes what each
generator writes, including the --separate-files layout.

Fixes DX-525
…adme

The docs site page is the reference for both generators' output, so
describing the --future-schema file layout here duplicates it and gives it
a second place to drift from the CLI.

Remove the section and the now-redundant "Legacy generator" heading, which
only existed to separate the two.

Fixes DX-525
…ename

Three defects a manual QA pass against a real space surfaced.

The emitted import of the field-plugins module had its extension stripped,
which is TS2835 under `moduleResolution: node16`/`nodenext` in an ESM
package. The file is generated code the user is told not to edit, so they
had no way to repair it. Map the extension instead (`.ts` to `.js`, `.mts`
to `.mjs`, `.cts` to `.cjs`): that form resolves under every mode, verified
across all combinations of the four resolution modes and both package
types, so it is strictly wider than the extension-less form.

`--filename` is documented as taking a base name, but the documented
default spells out `.d.ts`, so passing that value produced
`my-types.d.ts.d.ts`. Extract toDeclarationFileName and share it with the
legacy generator, so the same flag cannot mean two things depending on
--future-schema.

The unmapped-field-type warning hardcoded the default convention path. That
is wrong under `--path`, which moves it, and redundant once a module has
been loaded. Carry the searched path on the `none` source and name either
the module in use or the path this run actually looked at.

Fixes DX-525
…le blocks

Two defects a second manual QA pass against a real space surfaced.

498d1e6 fixed the extension-less import specifier in the wrong place. It
corrected `toRelativeImport`, which only ever serves the field-plugins module
path, while the block imports under `--separate-files` built their specifier
inline and never went through it. So the surface file still emitted
`from './blocks/hero'`, which is TS2834 under `moduleResolution:
node16`/`node18`/`nodenext` in an ESM package — every modern Node-ESM project.
Nothing caught it: both fixtures render single-file output, so
`emitted-types.test-d.ts` never typechecked a separate-files file, and two
tests asserted the broken specifier outright.

Add toDeclarationImportSpecifier beside toDeclarationFileName, so the written
file name and the specifier that has to match it cannot drift apart. It cannot
reuse toRelativeImport, which maps a trailing `.ts` and would turn
`blocks/hero.d.ts` into `blocks/hero.d.js`. Guard the invariant over the
emitted text rather than per call site: assert that no relative specifier the
renderer emits lacks a JavaScript extension, in either mode, so a future inline
call site fails too.

`blocks/` was also never reconciled. A component deleted in the UI left its
type file behind, still importable and describing a block that no longer
exists, and switching back to single-file output orphaned the whole directory.
Delete the block declarations a run did not write, and report the count rather
than removing files silently. Scoped to `*.d.ts` directly inside `blocks/`:
the output directory is shared with the legacy generator, so pruning it
wholesale would take `storyblok-components.d.ts` with it.

Fixes DX-525
The warning fired for any --filename under --future-schema while asserting
the value "is also where the legacy generator writes". For --filename
my-types or --separate-files --filename shared that statement is false, so
the command told users their own path clashed with a file nothing writes.

Compare the resolved name against the legacy default instead, and move that
default out of actions.ts into constants.ts so both generators read one
source rather than repeating the literal.

Fixes DX-525
All four rejections shared one rationale covering field optionality, custom
fields and the JSON-schema compiler. None of it applies to --suffix, so
someone who passed that flag got an explanation of something they had not
asked about, while the actual reason stayed in a code comment.

Carry the reason alongside each flag and compose the message from it. Single-
and multi-flag cases are phrased separately so neither reads awkwardly.

Fixes DX-525
`FieldPluginsSource` collapsed "no file at the convention path" and "file
there, wrong export name" into one `none` case, so the unmapped-field-type
warning told users to place a module at a path where one already sat. That is
the case `schema init` produces: it writes a `schema` export with no
`fieldPlugins` key, so anyone following the docs hits it first and reads the
advice as the command failing to see their file.

Carry a `reason` on the `none` variant and run `findNearMissExport` for the
convention path too, not only explicit ones. The warning now names the export
to rename when a module is there, and only suggests creating one when nothing
is.

Fixes DX-525
`tab` and `section` group other fields in the editor UI and carry no value of
their own, so no API response has a key for them. Their `FieldTypeValueMap`
entries are already `never`, but the field mapping kept the key, surfacing them
as `key?: null` — offering a property nothing can fill on every block that uses
a tab.

Skip fields whose resolved value is `never`, on the read and write mappings
both. Fixed in the codegen template rather than the CLI's serializer so
hand-written `defineSchema` blocks benefit too. The tuple wrapping in
`HasNoValue` is required: a bare `extends never` distributes over the naked
type parameter and never matches.

A `custom` field with no registered plugin resolves to `PluginFieldValue`, and
a `bloks` field with an empty registry to `never[]`, so neither is dropped;
both are covered.
A hand-written validator passed as `value` satisfies the constraint and
registers its `fieldType`, so nothing reports it as unmapped, while the field
still resolves to the untyped fallback — the value type comes from
`~standard.types`, not from what `validate` returns. Reads as the declaration
having no effect, so say where the type is read from.
Also excludes the generated-types fixtures from formatting: the drift test
compares them byte-for-byte against renderSchemaTypes' own output.
The branch changes tools/openapi-codegen/templates/field.ts (HasNoValue drops
valueless layout fields from content types), so every consumer's copy of the
templates has to be regenerated together, not just the one this branch works on.

The diff is larger than that one change: main's repo-wide oxfmt pass reformatted
the templates but never regenerated the consumers, whose committed output
.prettierignore excludes from formatting. Regenerating therefore also brings
packages/*/src/generated/types back in sync with the formatted templates. There
is no semantic change in that part of the diff, only quoting and line wrapping.

The spec-derived output (packages/cli and packages/richtext
src/generated/overlay/types.gen.ts) stays at main's committed content on
purpose. Regenerating it flips @hey-api's emitted interfaces to type aliases,
because main's committed copy predates the current generator version, and
packages/richtext/src/static/generate/richtext-element-types.ts only recognizes
interface declarations. It would silently emit an empty
StoryblokRichTextElementByType and break @storyblok/angular:build. That is a
pre-existing landmine on main and needs its own fix.
Restores what the rebase onto main dropped and adapts the branch's tests to
main's UI module:

- generateSchemaFile emits `FieldPlugins`, `Block<Name>`, and `AnyBlock` again,
  and imports `BlockContent` only when the space has components. The branch
  extracted this file's helpers into ../to-dsl-field and ../utils; resolving that
  conflict in favour of the branch also reverted main's additions here.
- The types generate tests assert on the mocked UI instead of `konsola`, which
  main replaced with `getUI()`, and the mock now exposes `error` so handleError
  can route CommandErrors through it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant