Enums

Layer: Backend · a field type + its frontend display

An enum field pins a column to a fixed set of string choices owned by a Python StrEnum. Reach for one when a column is a small closed vocabulary — a status, category, or lifecycle state.

How it works

Codegen imports your StrEnum by its dotted path — it never invents values — annotates the pydantic schema, inlines the choices into enum filters, and emits a TypeScript string-union that types the scaffolded display map.

The config you write

You writebe/inventory/models/asset.py (the StrEnum + its column):

class AssetStatus(enum.StrEnum):
    """Lifecycle state -- drives the FE filter dropdown."""

    # enum.auto() lowercases each member name into its wire value, so
    # members rename in Python without re-typing the string.
    AVAILABLE = enum.auto()
    ASSIGNED = enum.auto()
    IN_REPAIR = enum.auto()
    RETIRED = enum.auto()


# The column binds to it with TextEnum. Codegen resolves the dotted path
# "inventory.models.AssetStatus" against THIS class -- it must exist before
# codegen runs; codegen imports it, it does not generate it.
status: Mapped[AssetStatus] = mapped_column(
    TextEnum(AssetStatus),
    default=AssetStatus.AVAILABLE,
    nullable=False,
)

You writebe/config/inventory_resources/asset.jsonnet (the enum field + enum filter):

{
  representations: [
    {
      name: "list_item",
      fields: [
        fields.id(),
        { name: "name", type: "str" },
        // type: "enum" + dotted enum: path. Works anywhere a field list does
        // -- read dumps, create/update bodies, comms_summary. See fields.md.
        { name: "status", type: "enum", enum: "inventory.models.AssetStatus" },
      ],
    },
  ],

  operations: [
    list.searchable(
      representation="list_item",
      filters=[
        { name: "id", values: "self" },
        { name: "category_id", values: "ref", ref_resource: "category" },
        // An enum list filter: values: "enum" + the same dotted path. Choices
        // inline in GET /_filters and serve from the field's _values endpoint;
        // defaults to eq + in operators. See search-and-filters.md.
        { name: "status", values: "enum", enum: "inventory.models.AssetStatus" },
      ],
      order=["name"],
      default_order="name",
    ),
  ],
}

The frontend codegen emits

Codegen emits the enum as a string-union, regenerated every run:

Generatedfe/src/_generated/types/inventory/asset.gen.ts (do not edit):

/** Lifecycle state -- drives the FE filter dropdown. */
export type AssetStatus = "available" | "assigned" | "in_repair" | "retired";

Then it scaffolds a display module once (safe to edit — re-running codegen won’t overwrite it). defineEnumDisplay<AssetStatus> is typed against the union, so a value the backend adds later fails to compile here until you give it a label and variant:

Scaffolded, safe to editfe/src/enums/assetStatus.ts:

import { defineEnumDisplay } from "@fsh/components-library";

import type { AssetStatus } from "../_generated/types/inventory/asset.gen";

// variant is a Badge colour role: success / primary / warning / danger /
// neutral. projectType.ts colours every value "neutral" because those values
// aren't status-like. Unknown values degrade to their raw string + a neutral
// badge, so a backend running ahead never blanks the UI.
export const assetStatus = defineEnumDisplay<AssetStatus>({
  available: { label: "Available", variant: "success" },
  assigned: { label: "Assigned", variant: "primary" },
  in_repair: { label: "In repair", variant: "warning" },
  retired: { label: "Retired", variant: "danger" },
});

The display object exposes everything a render site needs, so no call site re-derives the mapping with ad-hoc ternaries:

You writefe/src/fields/inventory/asset.tsx + fe/src/actions/inventory/asset.tsx:

// Badge cell: badgeProps(value) spreads straight into <Badge>.
field("status", {
  header: "Status",
  cell: (row) => <Badge {...assetStatus.badgeProps(row.status)} />,
  table: { resizable: true, width: "sm" },
}),

// Select input: useSelectProps() is a searchable, locally-filtered picker
// bundle. Also on the display: .selectProps (plain, non-searchable),
// .options ({value,label}[] for filter dropdowns), .label(v), .values.
<SelectInput name="status" label="Status" {...assetStatus.useSelectProps()} />

For an enum that reaches the frontend but you never badge or pick (e.g. a ColumnType), use noEnumDisplay<V>("Name") instead of hand-colouring values you’ll never show — it satisfies the same type but throws on any access, so mis-wiring is loud at the call site. See fields, actions, and table configuration.