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..39e7f5791 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 `any` catch-all. + // 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() }) })