From 177be5c8265eb60b6ad23e3e6a93f21c163ba1c1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 31 Mar 2026 00:09:31 +1100 Subject: [PATCH 1/2] fix(types): use interface for NuxtConfigScriptRegistry to fix IDE display NuxtConfigScriptRegistry used a type intersection (mapped type & index signature) which caused IDEs to display known keys like googleMaps as NuxtConfigScriptRegistryEntry, losing autocomplete for script-specific properties. Replaced with an interface that extends the mapped type. In an interface, explicit properties inherited via extends always take priority over the index signature, making this immune to catch-all type contamination. ScriptRegistry augmentations flow through automatically. Also adds comprehensive type tests covering all 38 registry keys with not.toBeAny() assertions and structural property checks. --- packages/script/src/module.ts | 4 +- packages/script/src/runtime/types.ts | 22 +++++++-- packages/script/src/templates.ts | 5 +- test/types/types.test-d.ts | 71 +++++++++++++++++++++++++--- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 9a85918d8..e91bd2b62 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -119,7 +119,7 @@ export function isProxyDisabled( registry?: NuxtConfigScriptRegistry, runtimeConfig?: Record, ): boolean { - const entry = registry?.[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined + const entry = registry?.[registryKey] as NormalizedRegistryEntry | undefined if (!entry) return true const [input, scriptOptions] = entry @@ -143,7 +143,7 @@ export function applyAutoInject( if (isProxyDisabled(registryKey, registry, runtimeConfig)) return - const entry = registry[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry + const entry = registry[registryKey] as NormalizedRegistryEntry const input = entry[0] const rtScripts = runtimeConfig.public?.scripts as Record | undefined diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 344ef39bb..6784a27dd 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -252,9 +252,25 @@ export type RegistryScriptKey = Exclude type RegistryConfigInput = [T] extends [true] ? Record : T export type NuxtConfigScriptRegistryEntry = true | false | 'mock' | (RegistryConfigInput & { trigger?: NuxtUseScriptOptionsSerializable['trigger'] | false, proxy?: boolean, bundle?: boolean, partytown?: boolean, privacy?: ProxyPrivacyInput }) -export type NuxtConfigScriptRegistry = Partial<{ - [key in T]: NuxtConfigScriptRegistryEntry -}> & Record> + +// Internal mapped type: derives config entry types from ScriptRegistry. +// Excludes the `${string}-npm` pattern since it's covered by the string index signature. +type _NuxtConfigScriptRegistryEntries = { + [K in keyof ScriptRegistry as K extends `${string}-npm` ? never : K]?: NuxtConfigScriptRegistryEntry +} + +// Interface (not intersection) ensures IDE displays specific types for known keys. +// Explicit properties inherited via `extends` always take priority over the index +// signature, making this immune to catch-all type contamination. +// Augmenting ScriptRegistry automatically flows through to this type. +// +// The index signature uses `any` to satisfy TypeScript's constraint that all +// inherited properties must be subtypes of the index type. This is safe because +// in an interface, explicit properties always take priority over the index +// signature for property access. +export interface NuxtConfigScriptRegistry extends _NuxtConfigScriptRegistryEntries { + [key: string]: any +} export type UseFunctionType = T extends { use: infer V diff --git a/packages/script/src/templates.ts b/packages/script/src/templates.ts index c99098a19..85e8119fc 100644 --- a/packages/script/src/templates.ts +++ b/packages/script/src/templates.ts @@ -115,14 +115,15 @@ export function templatePlugin(config: Partial, registry: Require for (const [k, c] of Object.entries(config.registry || {})) { if (c === false) continue - const [, scriptOptions] = c as [Record, any?] + const entry = c as unknown as [Record, any?] + const [, scriptOptions] = entry if (!scriptOptions?.trigger) continue const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`) if (importDefinition) { resolvedRegistryKeys.push(k) imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`) - const [input] = c as [Record, any?] + const [input] = entry const opts = { ...scriptOptions } const triggerResolved = resolveTriggerForTemplate(opts.trigger) if (triggerResolved) { diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 92a212833..c78300f25 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -2,6 +2,7 @@ import type { ModuleOptions } from '../../packages/script/src/module' import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp' import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics' import type { + NuxtConfigScriptRegistry, NuxtUseScriptOptions, RegistryScriptInput, ScriptRegistry, @@ -10,14 +11,72 @@ import type { import { describe, expectTypeOf, it } from 'vitest' describe('module options registry', () => { - it('registry entries are typed', () => { - // Check specific registry keys have proper types (not any) - // Using specific keys because the index signature `[key: \`${string}-npm\`]` - // causes `keyof ScriptRegistry` to include template literals which resolve to any - type Registry = NonNullable - expectTypeOf().not.toBeAny() + type Registry = NonNullable + + it('all registry entries are typed, not any', () => { + // Every built-in registry key must resolve to its specific type, not `any`. + // NuxtConfigScriptRegistry is an interface (not an intersection), so explicit + // properties inherited via `extends` always take priority over the index signature. + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() + }) + + it('known keys resolve to their exact entry type, not the catch-all', () => { + // Known registry keys must resolve to NuxtConfigScriptRegistryEntry, + // not the index signature's NuxtConfigScriptRegistryEntry>. + // The interface approach guarantees this: inherited properties from `extends` always + // take priority over the index signature. + type GoogleMapsEntry = NuxtConfigScriptRegistry['googleMaps'] + type CatchAllEntry = NuxtConfigScriptRegistry[string] + // Known key must NOT equal the catch-all + expectTypeOf().not.toEqualTypeOf() + + // Verify specific input properties survive (not collapsed to unknown) + type ObjectForm = Exclude + expectTypeOf['apiKey']>().not.toBeNever() + expectTypeOf['id']>().not.toBeNever() + expectTypeOf['id']>().not.toBeNever() + }) + + it('registry allows unknown keys as catch-all', () => { + // Unknown keys fall through to the index signature (any), so custom scripts work + expectTypeOf().toBeAny() }) }) From bcc945f78ace9316bffd1e096ff5f91c853771d8 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 31 Mar 2026 00:19:38 +1100 Subject: [PATCH 2/2] fix: update stale comment in type test Co-authored-by: Copilot --- test/types/types.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index c78300f25..39e7f5791 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -59,7 +59,7 @@ describe('module options registry', () => { it('known keys resolve to their exact entry type, not the catch-all', () => { // Known registry keys must resolve to NuxtConfigScriptRegistryEntry, - // not the index signature's NuxtConfigScriptRegistryEntry>. + // not the index signature's `any` catch-all. // The interface approach guarantees this: inherited properties from `extends` always // take priority over the index signature. type GoogleMapsEntry = NuxtConfigScriptRegistry['googleMaps']