Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/frank-actors-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `InferLoaderSchema` type inference for content collections defined with a loader that includes a `schema`
5 changes: 3 additions & 2 deletions packages/astro/templates/content/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,12 @@ declare module 'astro:content' {
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = Required<ContentConfig['collections'][C]>['loader'],
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<Required<ContentConfig['collections'][C]>['loader']['schema']>
? import('astro/zod').infer<L['schema']>
: any;

type DataEntryMap = {
Expand Down
83 changes: 83 additions & 0 deletions packages/astro/test/content-collections-type-inference.test.js
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof loadFixture>>} */
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}`,
);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/content-collections-type-inference",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<html><body><h1>Type Inference Test Fixture</h1></body></html>
Original file line number Diff line number Diff line change
@@ -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
// 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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading