Skip to content

feat(types)!: enforce strict type narrowing for Link and Script unions#729

Merged
harlan-zw merged 2 commits into
mainfrom
feat/strict-type-narrowing
Apr 7, 2026
Merged

feat(types)!: enforce strict type narrowing for Link and Script unions#729
harlan-zw merged 2 commits into
mainfrom
feat/strict-type-narrowing

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Apr 7, 2026

Copy link
Copy Markdown
Collaborator

❓ Type of change

  • ⚠️ Breaking change
  • 👌 Enhancement

📚 Description

The GenericLink and GenericScript fallback types silently absorbed all known rel/type values in the Link and Script discriminated unions, preventing TypeScript from enforcing per-type constraints. For example, { rel: 'preload', as: 'font', href: '...' } compiled without error even though PreloadFontLink requires crossorigin.

This PR removes the generic fallbacks from the unions so the type system enforces:

  • rel="preload" + as="font" requires crossorigin
  • rel="mask-icon" requires color
  • Inline scripts can't have src, external scripts can't have textContent
  • Meta content is required (use null explicitly for removal)

GenericLink and GenericScript remain exported for explicit use with custom values via satisfies.

⚠️ Breaking Changes

  • Link union no longer includes GenericLink (custom rel values need explicit typing)
  • Script union no longer includes GenericScript (custom type values need explicit typing)
  • InlineScript/InlineModuleScript changed from interface to type (now accepts innerHTML as alternative to textContent)
  • Meta content is now required in UnheadMeta (use content: null for removal instead of omitting)

📝 Migration

// Before: silently matched GenericLink, no crossorigin enforcement
useHead({ link: [{ rel: 'preload', as: 'font', href: '/f.woff2' }] })

// After: TypeScript error — crossorigin is required
useHead({ link: [{ rel: 'preload', as: 'font', href: '/f.woff2', crossorigin: 'anonymous' }] })

// Custom rel values: use GenericLink explicitly
import type { GenericLink } from 'unhead/types'
useHead({ link: [{ rel: 'me', href: '...' } satisfies GenericLink] })

// Meta removal: use null instead of omitting content
useHead({ meta: [{ name: 'description', content: null }] })

Summary by CodeRabbit

  • Breaking Changes

    • Meta tags now require an explicit content property (use null to remove tags).
    • Link elements with custom rel values must be explicitly typed/handled.
    • Script elements must provide either textContent or innerHTML (mutually exclusive).
  • Tests

    • Unit/SSR tests updated to use explicit casts or typed literals; runtime behavior unchanged.

Remove GenericLink from Link union and GenericScript from Script union
so TypeScript enforces per-rel and per-type constraints. For example,
rel="preload" + as="font" now requires crossorigin, and rel="mask-icon"
requires color.

Also makes meta content required (accepts null for removal) and allows
innerHTML as an alternative to textContent in InlineScript.

GenericLink and GenericScript are still exported for explicit use with
custom rel/type values via `satisfies`.
@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR tightens schema types (making meta content required), removes generic fallback members from Link and Script exported unions, refactors inline script types to require either textContent or innerHTML, and adds/adjusts type assertions/casts in plugin code and tests to satisfy the stricter typings.

Changes

Cohort / File(s) Summary
Meta type strictness
packages/unhead/src/types/schema/head.ts
Made content required (nullable) for NameMeta, PropertyMeta, and HttpEquivMeta variants; JSDoc updated to state null explicitly removes a meta tag.
Link discriminated union
packages/unhead/src/types/schema/link.ts
Removed GenericLink from the exported Link union; docs updated to recommend satisfies GenericLink for non-standard rel values and provided examples.
Script discriminated union & inline scripts
packages/unhead/src/types/schema/script.ts
Converted InlineScript/InlineModuleScript from interfaces to union types requiring either textContent or innerHTML; removed GenericScript from exported Script union and updated docs/warnings (XSS note for innerHTML).
Plugin pushes / casts
packages/unhead/src/plugins/inferSeoMetaPlugin.ts
Now pushes og:title and og:description meta entries without content and appends } as any casts to those pushed objects (type assertion only).
Script defaults typing
packages/unhead/src/scripts/useScript.ts
Widened defaults from RawInput<'script'> to Partial<RawInput<'script'>> (typing change; runtime defaults unchanged).
Parser push typing
packages/unhead/src/parser/index.ts
Cast pushed scriptAttrs as any when pushing into input.script to satisfy stricter types.
Test casts and expectations
packages/unhead/test/..., packages/vue/test/... (multiple files)
Added as any casts and // @ts-expect-error`` annotations in tests: streaming, deduping, normalise, validate, and SSR tests adjusted to reflect stricter typings or to mark intentionally-invalid payloads.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through types both near and far,
Tightened content fields and sharpened each bar.
Generic fallbacks gone, discriminants sing,
Tests got a sprinkle of as any bling.
A rabbit cheers — precise types are the star! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: enforcing strict type narrowing for Link and Script unions by removing generic fallbacks.
Description check ✅ Passed The description comprehensively covers type of change, rationale, breaking changes, and migration examples, addressing all key sections of the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/strict-type-narrowing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.6 kB 4.3 kB
Server (Minimal) 10.3 kB 4.2 kB
Vue Client (Minimal) 11.6 kB 4.8 kB
Vue Server (Minimal) 11.3 kB 4.6 kB

Parser builds scripts from HTML attributes (index signature) and
useScript creates defaults incrementally; both need looser types
since they don't construct complete discriminated union members.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/unhead/src/parser/index.ts (1)

512-512: Replace as any with a more specific type for parsed scripts.

Line 512 uses as any to cast dynamically-assembled script attributes from HTML parsing. This removes type guarantees and risks masking invalid payloads. Use as GenericScript instead, which is explicitly documented as the escape hatch for custom/non-standard script types and provides narrower type safety than any.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/parser/index.ts` at line 512, Replace the unsafe cast "as
any" on the parsed script attributes with the documented narrower escape hatch
type GenericScript: in the code that pushes parsed scripts (the statement using
input.script and the variable scriptAttrs) change the cast to use GenericScript
so the pushed values are typed; also ensure GenericScript is imported or
exported where needed and that the input.script array type can accept
GenericScript to preserve type safety across the parser functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/unhead/src/parser/index.ts`:
- Line 512: Replace the unsafe cast "as any" on the parsed script attributes
with the documented narrower escape hatch type GenericScript: in the code that
pushes parsed scripts (the statement using input.script and the variable
scriptAttrs) change the cast to use GenericScript so the pushed values are
typed; also ensure GenericScript is imported or exported where needed and that
the input.script array type can accept GenericScript to preserve type safety
across the parser functions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04573f5c-f4c1-45a3-8f63-220185a4ac06

📥 Commits

Reviewing files that changed from the base of the PR and between 8979379 and 13d654e.

📒 Files selected for processing (2)
  • packages/unhead/src/parser/index.ts
  • packages/unhead/src/scripts/useScript.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/unhead/src/scripts/useScript.ts

@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

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