Skip to content

RFC: Eliminate 4-way config type duplication via field() primitive #1348

Description

@VampireChicken12

Problem

Feature configuration shapes are described in four redundant places, and button config is copy-pasted across 8+ features:

  1. Global configuration type (src/types/index.ts:228-353) — a 125-line TypeScript union, one key per feature
  2. schemaInput (each feature's index.metadata.ts) — a Zod schema that must exactly mirror configuration[featureId]
  3. defaults (each feature's index.metadata.ts) — default values validated against schemaInput at registration time
  4. settings[].id strings like "loopButton.button.enabled" — plain strings with no type-level path validation

Add a new field or rename one, and you touch 3-4 places per feature x 57 features = ~200 edit points. The worst offender is the button config { enabled: boolean, fullscreenPlacement: FullscreenPlacement, placement: ButtonPlacement }, which is manually copy-pasted in 8+ metadata files (loopButton, screenshotButton, flipVideoButtons, maximizePlayerButton, monoToStereoButton, openTranscriptButton, hideEndScreenCardsButton, etc.).

The metadata files (index.metadata.ts) must remain separate from feature implementation files (index.ts) because they are loaded eagerly by import.meta.glob in contexts where browser APIs are unavailable (popup, options page).

Proposed Interface

The field() primitive

Packages a Zod type + its default value into one object — the single source of truth for both schemaInput and defaults:

interface Field<out T, out D extends T = T> {
  readonly _field: true;
  readonly schema: z.ZodMiniType<T>;
  readonly default: D;
}

function field<T, D extends T>(schema: z.ZodMiniType<T>, defaultValue: D): Field<T, D>

Type and value derivation

Two runtime extractors walk the config tree to produce defaults and schemaInput:

type InferType<T extends ConfigShape> = {
  [K in keyof T]: T[K] extends Field<infer V> ? V
    : T[K] extends ConfigShape ? InferType<T[K]>
    : never;
};

function extractDefaults(shape): InferType<typeof shape>  // collects .default from each Field
function extractSchemaInput(shape): Record<string, any>   // collects .schema from each Field

Reusable button config composable

export const buttonField = {
  enabled: field(z.boolean(), false),
  fullscreenPlacement: field(z.enum(fullscreenPlacements), "same" as const),
  placement: field(z.enum(buttonPlacements), "feature_menu" as const),
} satisfies ConfigShape;

Any feature with a button does { ...buttonField, placement: field(...) } instead of repeating the 6 lines.

Updated createFeatureMetadata

Accepts config instead of separate defaults + schemaInput. Derives both internally:

export function createFeatureMetadata<K extends FeatureKeys>(
  input: Omit<FeatureMetadata<K>, "defaults" | "schemaInput"> & {
    config: ConfigShape;
  },
): FeatureMetadata<K> {
  const defaults = extractDefaults(input.config);
  const schemaInput = extractSchemaInput(input.config);
  return { ...input, defaults, schemaInput } as FeatureMetadata<K>;
}

Usage: simple feature (hideScrollBar)

export const metadata = createFeatureMetadata({
  id: "hideScrollBar",
  config: { enabled: field(z.boolean(), false) },
  settings: [{ component: "checkbox", id: "hideScrollBar.enabled", label, title }],
});
// defaults = { enabled: false }, schemaInput = { enabled: z.boolean() } -- derived

Usage: button feature (loopButton)

export const metadata = createFeatureMetadata({
  id: "loopButton",
  dependencies: { includePages: ["watch"] },
  config: { button: { ...buttonField, placement: field(z.enum(buttonPlacements), "feature_menu") } },
  settings: [{ component: "checkbox", id: "loopButton.button.enabled", label, title }],
});

Usage: complex feature (volumeBoost)

export const metadata = createFeatureMetadata({
  id: "volumeBoost",
  dependencies: { includePages: ["watch", "live", "shorts"] },
  config: {
    enabled: field(z.boolean(), false),
    mode: field(z.enum(volumeBoostModes), "global"),
    amount: field(z.number(), 5),
    button: { ...buttonField, placement: field(z.enum(buttonPlacements), "player_controls_left") },
  },
  sectionTitle: (t) => t((tr) => tr.settings.sections.volumeBoost.title),
  settings: [
    {
      type: "group", section: "volumeBoost",
      children: [
        { component: "checkbox", id: "volumeBoost.enabled", label, title },
        { component: "select", id: "volumeBoost.mode", disabledWhen: [...], ... },
        { component: "number", id: "volumeBoost.amount", min: 1, max: 100, step: 1, ... },
      ],
    },
  ],
});

What complexity it hides internally

  • Runtime: extractDefaults() and extractSchemaInput() recursively walk the ConfigShape tree, collecting .default and .schema from each Field leaf into matching tree structures
  • Type-level: InferType<T> recursively maps Field<V> -> V to produce the TypeScript type that lifecycle handlers receive (onEnable(config: InferType<...>))
  • Registration: The glob-based auto-discovery in featureMetadataRegistry.ts:322 works unchanged — it still reads schemaInput + defaults from the metadata object

What stays the same

Aspect Status
index.metadata.ts separation Preserved — pure file, no browser deps
settings array UI metadata Still hand-written (labels, components, conditions are irreducible)
Setting id strings Still raw strings (no regression)
import.meta.glob discovery Works unchanged
validateDefaults() Receives same format defaults + schemaInput
Global configuration type Stays hand-written in src/types/index.ts
Feature lifecycle handlers Still receive configuration[K] — unchanged
Existing metadata files Backward compatible (or one-time migration)

Dependency Strategy

In-process. This is pure build-time / init-time computation. The field() primitive, extractors, and createFeatureMetadata update all run in the same process as the existing registration code. No external services, no I/O, no network dependencies.

Testing Strategy

There are currently zero test files in the repository. After this refactor:

New boundary tests to write

  • extractDefaults — given a ConfigShape tree, assert the output matches expected defaults object
  • extractSchemaInput — given a ConfigShape tree, assert the output is a valid Zod shape
  • createFeatureMetadata integration — given a config + settings, assert the resulting metadata passes validateDefaults() and validateSettingsIds() from the existing registry
  • Type-level equality — assert InferType<typeof config> is structurally identical to the corresponding slice of configuration (catches type drift at compile time)

Old tests to delete

N/A — no tests exist. No regression risk.

Test environment needs

  • Zod v4-mini (already a dependency)
  • Vitest or Node test runner (not yet in the project — would need to be added)

Implementation Recommendations

What the module should own

  • A single defineConfig.ts file in src/features/_registry/ containing:
    • The Field type and field() factory
    • The ConfigShape type and InferType utility type
    • extractDefaults() and extractSchemaInput() runtime extractors
    • The shared buttonField composable

What it should hide

  • The recursive walking logic that produces defaults and schemaInput from a ConfigShape
  • The type-level mapping from Field<V> to plain TypeScript V
  • The fact that there were ever two separate declarations for schema and defaults

What it should expose

  • field() — the only primitive a feature developer needs
  • buttonField — the reusable button shape (or similar for other common patterns)
  • createFeatureMetadata() — updated to accept config key (backward compatible or as a new overload)

Migration path

  1. Create defineConfig.ts with the field() primitive, extractors, and buttonField
  2. Update createFeatureMetadata to accept the config key
  3. Migrate one feature at a time (start with hideScrollBar -> loopButton -> ...)
  4. Both old (defaults + schemaInput) and new (config) can coexist during migration
  5. After all 57 features are migrated, remove the old parameters from createFeatureMetadata

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions