From 08b396bfe4d274cd3d87da302a8bfc3050573d1e Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Fri, 13 Mar 2026 17:32:28 +0000 Subject: [PATCH 1/4] fix(content): fix inferred schema types for custom loaders with CollectionConfig union --- packages/astro/templates/content/types.d.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/astro/templates/content/types.d.ts b/packages/astro/templates/content/types.d.ts index 0589f06b67a8..54e4a92f69b6 100644 --- a/packages/astro/templates/content/types.d.ts +++ b/packages/astro/templates/content/types.d.ts @@ -111,11 +111,12 @@ declare module 'astro:content' { type InferEntrySchema = import('astro/zod').infer< ReturnTypeOrOriginal['schema']> >; + type ExtractLoaderConfig = T extends { loader: infer L } ? L : never; type InferLoaderSchema< C extends keyof DataEntryMap, - L = Required['loader'], + L = ExtractLoaderConfig, > = L extends { schema: import('astro/zod').ZodSchema } - ? import('astro/zod').infer['loader']['schema']> + ? import('astro/zod').infer : any; type DataEntryMap = { From da1518083b9eb70a8d0e309b966d22dfebe33a5c Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:31:31 +0100 Subject: [PATCH 2/4] test: create fixture and tests for type inference --- ...content-collections-type-inference.test.js | 83 +++++++++++++++++++ .../astro.config.mjs | 3 + .../package.json | 9 ++ .../src/content.config.ts | 37 +++++++++ .../src/pages/index.astro | 1 + .../src/type-checks.ts | 56 +++++++++++++ .../tsconfig.json | 5 ++ pnpm-lock.yaml | 6 ++ 8 files changed, 200 insertions(+) create mode 100644 packages/astro/test/content-collections-type-inference.test.js create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/astro.config.mjs create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/package.json create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/src/content.config.ts create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts create mode 100644 packages/astro/test/fixtures/content-collections-type-inference/tsconfig.json diff --git a/packages/astro/test/content-collections-type-inference.test.js b/packages/astro/test/content-collections-type-inference.test.js new file mode 100644 index 000000000000..a8d0916f1342 --- /dev/null +++ b/packages/astro/test/content-collections-type-inference.test.js @@ -0,0 +1,83 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { loadFixture } from './test-utils.js'; + +describe('Content collection type inference', () => { + /** @type {Awaited>} */ + let fixture; + /** @type {string} */ + let fixtureRoot; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/content-collections-type-inference/', + }); + fixtureRoot = fileURLToPath(fixture.config.root); + + // Clean previous .astro directory + fs.rmSync(new URL('./.astro/', fixture.config.root), { force: true, recursive: true }); + + // Run astro sync to generate .astro/content.d.ts from the real template + await fixture.sync({ root: fixtureRoot }); + }); + + it('generates content.d.ts with ExtractLoaderConfig type utility', async () => { + const contentDts = fs.readFileSync( + new URL('./.astro/content.d.ts', fixture.config.root), + 'utf-8', + ); + assert.ok( + contentDts.includes('ExtractLoaderConfig'), + 'Generated content.d.ts should contain ExtractLoaderConfig type utility', + ); + assert.ok( + contentDts.includes('InferLoaderSchema'), + 'Generated content.d.ts should contain InferLoaderSchema type utility', + ); + }); + + it('generates correct DataEntryMap with all three collection types', async () => { + const contentDts = fs.readFileSync( + new URL('./.astro/content.d.ts', fixture.config.root), + 'utf-8', + ); + assert.ok(contentDts.includes('"blog"'), 'DataEntryMap should include "blog" collection'); + assert.ok(contentDts.includes('"legacy"'), 'DataEntryMap should include "legacy" collection'); + assert.ok( + contentDts.includes('"schemaless"'), + 'DataEntryMap should include "schemaless" collection', + ); + }); + + it('type-checks correctly against the generated types', () => { + // Run tsc on the fixture to verify the type assertions in src/type-checks.ts + // pass against the real generated content.d.ts. + // + // The type-checks.ts file uses @ts-expect-error to assert that: + // - Case 1 (loader with schema): data is NOT any, is { test: string } + // - Case 2 (legacy schema): data is NOT any, is { title: string; legacyField: boolean } + // - Case 3 (schemaless loader): data IS any (the correct fallback) + // + // If any @ts-expect-error is unused (type collapsed to `any` when it shouldn't), + // tsc will report an error and this test fails. + try { + execSync('npx tsc --noEmit', { + cwd: fixtureRoot, + stdio: 'pipe', + encoding: 'utf-8', + }); + } catch (err) { + const stdout = /** @type {{ stdout?: string }} */ (err).stdout ?? ''; + const stderr = /** @type {{ stderr?: string }} */ (err).stderr ?? ''; + assert.fail( + `TypeScript type-checking failed on fixture.\n` + + `This means the content collection type inference is broken.\n\n` + + `stdout:\n${stdout}\n\nstderr:\n${stderr}`, + ); + } + }); +}); diff --git a/packages/astro/test/fixtures/content-collections-type-inference/astro.config.mjs b/packages/astro/test/fixtures/content-collections-type-inference/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/content-collections-type-inference/package.json b/packages/astro/test/fixtures/content-collections-type-inference/package.json new file mode 100644 index 000000000000..8eb07dd84cce --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/content-collections-type-inference", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-collections-type-inference/src/content.config.ts b/packages/astro/test/fixtures/content-collections-type-inference/src/content.config.ts new file mode 100644 index 000000000000..98a6fb124730 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/src/content.config.ts @@ -0,0 +1,37 @@ +import { defineCollection } from 'astro:content'; +import { z } from 'astro/zod'; +import type { Loader } from 'astro/loaders'; + +function myLoader() { + return { + name: 'my-loader', + load: async () => {}, + schema: z.object({ + test: z.string(), + }), + } satisfies Loader; +} + +// Case 1: Loader with schema defined on the loader object +const blog = defineCollection({ + loader: myLoader(), +}); + +// Case 2: Legacy collection with schema on the collection (no loader) +const legacy = defineCollection({ + schema: z.object({ + title: z.string(), + legacyField: z.boolean(), + }), +}); + +// Case 3: Loader with no schema at all +const schemaless = defineCollection({ + loader: async () => [{ id: '1' }], +}); + +export const collections = { + blog, + legacy, + schemaless, +}; diff --git a/packages/astro/test/fixtures/content-collections-type-inference/src/pages/index.astro b/packages/astro/test/fixtures/content-collections-type-inference/src/pages/index.astro new file mode 100644 index 000000000000..2c1a7371dadb --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/src/pages/index.astro @@ -0,0 +1 @@ +

Type Inference Test Fixture

diff --git a/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts b/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts new file mode 100644 index 000000000000..c718f97c920b --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts @@ -0,0 +1,56 @@ +/** + * Type assertions for content collection type inference. + * + * This file is NOT executed at runtime. It is type-checked by tsc after + * `astro sync` generates the .astro/content.d.ts types for this fixture. + * + * Each @ts-expect-error comment asserts that the line below it IS a type error, + * meaning the type is NOT `any` or `never` where it shouldn't be. + * + * If the patch in templates/content/types.d.ts regresses, tsc will fail here + * because a @ts-expect-error will become unused (the type collapsed to `any`). + */ +import type { CollectionEntry, InferLoaderSchema } from 'astro:content'; + +// ============================================================================ +// Case 1: Loader with schema on the loader object ("blog" collection) +// The patched ExtractLoaderConfig should correctly extract the loader's schema. +// ============================================================================ + +type BlogEntry = CollectionEntry<'blog'>; +type BlogData = BlogEntry['data']; + +// BlogData should be { test: string }, NOT any. +// If it were `any`, assigning a number to a string field would not error. +// @ts-expect-error - `test` is string, not number +const _blogDataCheck: BlogData = { test: 123 }; + +type InferredBlogSchema = InferLoaderSchema<'blog'>; +// @ts-expect-error - `test` is string, not number +const _inferredBlogCheck: InferredBlogSchema = { test: 123 }; + +// ============================================================================ +// Case 2: Legacy collection with schema on the collection ("legacy") +// Should NOT be broken by the ExtractLoaderConfig patch. +// ============================================================================ + +type LegacyData = CollectionEntry<'legacy'>['data']; + +// LegacyData should be { title: string; legacyField: boolean }. +// @ts-expect-error - `title` is string, not number +const _legacyTitleCheck: LegacyData = { title: 123, legacyField: true }; +// @ts-expect-error - `legacyField` is boolean, not string +const _legacyFieldCheck: LegacyData = { title: 'ok', legacyField: 'not a boolean' }; + +// ============================================================================ +// Case 3: Loader with no schema ("schemaless" collection) +// Should fall back to `any` — this is the correct behavior. +// ============================================================================ + +type SchemalessData = InferLoaderSchema<'schemaless'>; + +// If the type is correctly `any`, then any assignment is valid and +// @ts-expect-error on a valid assignment would be an error itself. +// So we verify `any` by checking that arbitrary property access works: +const _schemalessValue: SchemalessData = { anything: 'goes', count: 42 }; +const _schemalessAccess: string = _schemalessValue.nonExistentProp; diff --git a/packages/astro/test/fixtures/content-collections-type-inference/tsconfig.json b/packages/astro/test/fixtures/content-collections-type-inference/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-type-inference/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb644db812e..e86fe1dffee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2792,6 +2792,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/content-collections-type-inference: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/content-collections-with-config-mjs: dependencies: astro: From 837faa4e2b030dd13dba4714977a1b048725b44d Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:34:03 +0100 Subject: [PATCH 3/4] docs: changeset --- .changeset/frank-actors-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/frank-actors-brush.md diff --git a/.changeset/frank-actors-brush.md b/.changeset/frank-actors-brush.md new file mode 100644 index 000000000000..d935dc111486 --- /dev/null +++ b/.changeset/frank-actors-brush.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `InferLoaderSchema` type inference for content collections defined with a loader that includes a `schema` From 3b04dee17459b3d08c20647d1b7d80444362bb0a Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:53:25 +0100 Subject: [PATCH 4/4] fix: expected too much --- .../content-collections-type-inference/src/type-checks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts b/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts index c718f97c920b..33a0fd944983 100644 --- a/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts +++ b/packages/astro/test/fixtures/content-collections-type-inference/src/type-checks.ts @@ -50,7 +50,7 @@ const _legacyFieldCheck: LegacyData = { title: 'ok', legacyField: 'not a boolean type SchemalessData = InferLoaderSchema<'schemaless'>; // If the type is correctly `any`, then any assignment is valid and -// @ts-expect-error on a valid assignment would be an error itself. +// a ts-expect-error on a valid assignment would be an error itself. // So we verify `any` by checking that arbitrary property access works: const _schemalessValue: SchemalessData = { anything: 'goes', count: 42 }; const _schemalessAccess: string = _schemalessValue.nonExistentProp;