shadimage
typedora-ui
componenttyped-selectsource: ok · hit

TypedSelect

A headless, type-safe select component with full TypeScript inference and customizable rendering.

npx shadcn add "https://typedora-ui.netlify.app/r/typed-select.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

selecthttps://typedora-ui.netlify.app/r/typed-utils.json

Files1 · source cached 10/14/2026, 8:45:31 PM

registry/default/typed-select/typed-select.tsx
"use client";

import * as React from "react";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import type { ExtractOption, ExtractOptionValue } from "@/lib/typed-utils";

// =============================================================================
// Types
// =============================================================================

/**
 * Supported value types for TypedSelect.
 * Values are serialized to strings internally but typed correctly externally.
 */
export type SelectValueType = string | number | boolean;

/**
 * Default option type with value and label.
 * Use this as a reference for your custom option types.
 */
export type SelectOption<T extends SelectValueType = string> = {
  value: T;
  label: string;
  disabled?: boolean;
};

/**
 * A group of select options with a label.
 * @template TOption - The full option type including custom properties
 */
export type SelectOptionGroup<TOption> = {
  label: string;
  options: TOption[];
};

// =============================================================================
// Render Props Types
// =============================================================================

/**
 * Props passed to renderTrigger function.
 * @template TOption - The full option type including custom properties
 */
export type SelectTriggerRenderProps<TOption> = {
  /** Currently selected option (undefined if none) */
  selectedOption: TOption | undefined;
  /** Placeholder text */
  placeholder: string;
  /** Whether the select is open */
  open: boolean;
  /** Whether the select is disabled */
  disabled: boolean;
  /** Default trigger component */
  Trigger: typeof SelectTrigger;
  /** Default value component */
  Value: typeof SelectValue;
};

/**
 * Props passed to renderItem function.
 * @template TOption - The full option type including custom properties
 */
export type SelectItemRenderProps<TOption> = {
  /** The option data */
  option: TOption;
  /** Whether this option is currently selected */
  isSelected: boolean;
  /** Whether this option is disabled */
  isDisabled: boolean;
  /** Group this option belongs to (undefined if ungrouped) */
  group: SelectOptionGroup<TOption> | undefined;
  /** Props to spread on SelectItem */
  itemProps: {
    value: string;
    disabled?: boolean;
  };
  /** Default item component */
  Item: typeof SelectItem;
};

/**
 * Props passed to renderGroup function.
 * @template TOption - The full option type including custom properties
 */
export type SelectGroupRenderProps<TOption> = {
  /** The group data */
  group: SelectOptionGroup<TOption>;
  /** Rendered items inside this group */
  children: React.ReactNode;
  /** Default group component */
  Group: typeof SelectGroup;
  /** Default label component */
  Label: typeof SelectLabel;
};

/**
 * Props passed to renderContent function.
 */
export type SelectContentRenderProps = {
  /** Rendered items/groups */
  children: React.ReactNode;
  /** Default content component */
  Content: typeof SelectContent;
};

/**
 * Core props for TypedSelect (for documentation).
 * The actual component also accepts all Select props.
 */
export interface TypedSelectBaseProps<
  TOptions extends
    | readonly Record<string, unknown>[]
    | readonly SelectOptionGroup<Record<string, unknown>>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
> {
  /** Array of options or grouped options */
  options: TOptions;
  /** The key to use for the option value (default: "value") */
  valueKey?: TValueKey;
  /** The key to use for the option label (default: "label") */
  labelKey?: TLabelKey;
  /** Controlled value */
  value?: ExtractOptionValue<TOptions, TValueKey>;
  /** Default value for uncontrolled usage */
  defaultValue?: ExtractOptionValue<TOptions, TValueKey>;
  /** Callback when value changes - receives the typed value */
  onValueChange?: (value: ExtractOptionValue<TOptions, TValueKey>) => void;
  /** Placeholder text (passed to renderTrigger) */
  placeholder?: string;
  /** Custom render for trigger */
  renderTrigger?: (
    props: SelectTriggerRenderProps<ExtractOption<TOptions>>,
  ) => React.ReactNode;
  /** Custom render for each item */
  renderItem?: (
    props: SelectItemRenderProps<ExtractOption<TOptions>>,
  ) => React.ReactNode;
  /** Custom render for each group (required when using grouped options) */
  renderGroup?: (
    props: SelectGroupRenderProps<ExtractOption<TOptions>>,
  ) => React.ReactNode;
  /** Custom render for content wrapper */
  renderContent?: (props: SelectContentRenderProps) => React.ReactNode;
  /** Whether the select is disabled */
  disabled?: boolean;
  /** Whether the select is open (controlled) */
  open?: boolean;
  /** Callback when open state changes */
  onOpenChange?: (open: boolean) => void;
  /** The name of the select (for form submission) */
  name?: string;
  /** Whether selection is required */
  required?: boolean;
}

/**
 * Props for the TypedSelect component.
 * @template TOptions - The options array type
 * @template TValueKey - The key used for the value property (default: "value")
 * @template TLabelKey - The key used for the label property (default: "label")
 */
export interface TypedSelectProps<
  TOptions extends
    | readonly Record<string, unknown>[]
    | readonly SelectOptionGroup<Record<string, unknown>>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
> extends Omit<
      React.ComponentProps<typeof Select>,
      "value" | "defaultValue" | "onValueChange" | "children"
    >,
    TypedSelectBaseProps<TOptions, TValueKey, TLabelKey> {}

// =============================================================================
// Helpers
// =============================================================================

/**
 * Type guard to check if options are grouped
 */
function isGroupedOptions<TOption>(
  options: readonly TOption[] | readonly SelectOptionGroup<TOption>[],
): options is readonly SelectOptionGroup<TOption>[] {
  return options.length > 0 && "options" in (options[0] as object);
}

/**
 * Serialize a value to string for internal use with Radix Select
 */
function serializeValue<T extends SelectValueType>(value: T): string {
  if (typeof value === "boolean") {
    return value ? "__boolean_true__" : "__boolean_false__";
  }
  if (typeof value === "number") {
    return `__number_${value}__`;
  }
  return String(value);
}

/**
 * Deserialize a string value back to its original type
 */
function deserializeValue<TOption extends Record<string, unknown>>(
  serialized: string,
  options: readonly TOption[] | readonly SelectOptionGroup<TOption>[],
  valueKey: keyof TOption,
): TOption[keyof TOption] {
  const allOptions = isGroupedOptions(options)
    ? options.flatMap((group) => group.options)
    : options;

  const found = allOptions.find(
    (opt) => serializeValue(opt[valueKey] as SelectValueType) === serialized,
  );

  return found?.[valueKey] as TOption[keyof TOption];
}

/**
 * Find the selected option object from the current value.
 * Handles both flat and grouped options.
 */
function findSelectedOption<TOption extends Record<string, unknown>>(
  value: TOption[keyof TOption] | undefined,
  options: readonly TOption[] | readonly SelectOptionGroup<TOption>[],
  valueKey: keyof TOption,
): TOption | undefined {
  if (value === undefined) return undefined;

  const allOptions = isGroupedOptions(options)
    ? options.flatMap((group) => group.options)
    : options;

  return allOptions.find((opt) => opt[valueKey] === value);
}

// =============================================================================
// Default Render Functions
// =============================================================================

function defaultRenderTrigger<TOption>({
  placeholder,
  Trigger,
  Value,
}: SelectTriggerRenderProps<TOption>): React.ReactNode {
  return (
    <Trigger>
      <Value placeholder={placeholder} />
    </Trigger>
  );
}

function createDefaultRenderItem<
  TOption extends Record<string, unknown>,
  TLabelKey extends keyof TOption,
>(labelKey: TLabelKey) {
  return function defaultRenderItem({
    option,
    itemProps,
    Item,
  }: SelectItemRenderProps<TOption>): React.ReactNode {
    const label = option[labelKey];
    return <Item {...itemProps}>{String(label ?? "")}</Item>;
  };
}

function defaultRenderContent({
  children,
  Content,
}: SelectContentRenderProps): React.ReactNode {
  return <Content>{children}</Content>;
}

// =============================================================================
// Component
// =============================================================================

/**
 * A headless, type-safe select component with full TypeScript inference
 * and customizable rendering via render props.
 *
 * @example
 * ```tsx
 * // Default usage with value/label keys
 * <TypedSelect
 *   placeholder="Select fruit"
 *   options={[
 *     { value: "apple", label: "Apple" },
 *     { value: "banana", label: "Banana", description: "Yellow fruit" },
 *   ] as const}
 *   renderTrigger={({ placeholder, selectedOption, Trigger, Value }) => (
 *     <Trigger>
 *       <Value placeholder={placeholder} />
 *     </Trigger>
 *   )}
 *   renderItem={({ option, itemProps, Item }) => (
 *     <Item {...itemProps}>
 *       <div>
 *         <span>{option.label}</span>
 *         {option.description && <p>{option.description}</p>}
 *       </div>
 *     </Item>
 *   )}
 *   renderContent={({ children, Content }) => (
 *     <Content>{children}</Content>
 *   )}
 *   onValueChange={(value) => {
 *     // value: "apple" | "banana"
 *   }}
 * />
 *
 * // Custom keys usage with id/name
 * <TypedSelect
 *   placeholder="Select user"
 *   options={[
 *     { id: 1, name: "John Doe" },
 *     { id: 2, name: "Jane Smith" },
 *   ] as const}
 *   valueKey="id"
 *   labelKey="name"
 *   onValueChange={(value) => {
 *     // value: 1 | 2
 *   }}
 * />
 * ```
 */
function TypedSelect<
  TOptions extends
    | readonly Record<string, unknown>[]
    | readonly SelectOptionGroup<Record<string, unknown>>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
>({
  options,
  valueKey = "value" as TValueKey,
  labelKey = "label" as TLabelKey,
  value,
  defaultValue,
  onValueChange,
  placeholder = "Select an option",
  open: controlledOpen,
  onOpenChange,
  disabled = false,
  renderTrigger = defaultRenderTrigger,
  renderItem,
  renderGroup,
  renderContent = defaultRenderContent,
  ...props
}: TypedSelectProps<TOptions, TValueKey, TLabelKey>) {
  // Extract the actual option type for internal use
  type TOption = ExtractOption<TOptions> & Record<string, unknown>;
  type TValue = ExtractOptionValue<TOptions, TValueKey>;

  // Track open state internally
  const [internalOpen, setInternalOpen] = React.useState(false);
  const open = controlledOpen ?? internalOpen;

  // Track internal value for uncontrolled mode
  const [internalValue, setInternalValue] = React.useState<TValue | undefined>(
    defaultValue,
  );

  // Use controlled value if provided, otherwise use internal state
  const currentValue = value !== undefined ? value : internalValue;

  const handleOpenChange = (newOpen: boolean) => {
    setInternalOpen(newOpen);
    onOpenChange?.(newOpen);
  };

  const handleValueChange = (serialized: string) => {
    const deserialized = deserializeValue(
      serialized,
      options as readonly TOption[] | readonly SelectOptionGroup<TOption>[],
      valueKey as keyof TOption,
    );

    // Update internal state for uncontrolled mode
    if (value === undefined) {
      setInternalValue(deserialized as TValue);
    }

    if (onValueChange) {
      onValueChange(deserialized as TValue);
    }
  };

  const selectedOption = findSelectedOption(
    currentValue as TOption[keyof TOption] | undefined,
    options as readonly TOption[] | readonly SelectOptionGroup<TOption>[],
    valueKey as keyof TOption,
  );
  const serializedValue =
    currentValue !== undefined
      ? serializeValue(currentValue as SelectValueType)
      : undefined;
  const serializedDefaultValue =
    defaultValue !== undefined
      ? serializeValue(defaultValue as SelectValueType)
      : undefined;

  // Use provided renderItem or create default with labelKey
  const actualRenderItem =
    renderItem ??
    createDefaultRenderItem<TOption, keyof TOption>(labelKey as keyof TOption);

  // Render a single item
  const renderSingleItem = (
    option: TOption,
    group: SelectOptionGroup<TOption> | undefined,
  ) => {
    const optionValue = option[valueKey as keyof TOption] as SelectValueType;
    const serialized = serializeValue(optionValue);
    const isSelected = serializedValue === serialized;
    const isDisabled = (option as { disabled?: boolean }).disabled ?? false;

    return (
      <React.Fragment key={serialized}>
        {actualRenderItem({
          option,
          isSelected,
          isDisabled,
          group,
          itemProps: {
            value: serialized,
            disabled: isDisabled,
          },
          Item: SelectItem,
        })}
      </React.Fragment>
    );
  };

  // Render all items (grouped or flat)
  const renderItems = () => {
    const opts = options as
      | readonly TOption[]
      | readonly SelectOptionGroup<TOption>[];

    if (isGroupedOptions(opts)) {
      return opts.map((group) => {
        const groupChildren = group.options.map((option) =>
          renderSingleItem(option, group),
        );

        if (renderGroup) {
          return (
            <React.Fragment key={group.label}>
              {renderGroup({
                group: group as SelectOptionGroup<ExtractOption<TOptions>>,
                children: groupChildren,
                Group: SelectGroup,
                Label: SelectLabel,
              })}
            </React.Fragment>
          );
        }

        // Default group rendering if renderGroup not provided
        return (
          <SelectGroup key={group.label}>
            <SelectLabel>{group.label}</SelectLabel>
            {groupChildren}
          </SelectGroup>
        );
      });
    }

    return (opts as readonly TOption[]).map((option) =>
      renderSingleItem(option, undefined),
    );
  };

  return (
    <Select
      value={serializedValue}
      defaultValue={serializedDefaultValue}
      onValueChange={handleValueChange}
      open={open}
      onOpenChange={handleOpenChange}
      disabled={disabled}
      {...props}
    >
      {renderTrigger({
        selectedOption: selectedOption as ExtractOption<TOptions> | undefined,
        placeholder,
        open,
        disabled,
        Trigger: SelectTrigger,
        Value: SelectValue,
      })}
      {renderContent({
        children: renderItems(),
        Content: SelectContent,
      })}
    </Select>
  );
}

export { TypedSelect };

More from typedora-ui

Browse all components in this library →