useSchema
Takes one FieldDefinition[] and gives back everything derived from it: the form fields, the table columns, the groups and a Zod schema. All reactive.
vue
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import { useSchema } from '@core/schema/useSchema'
import { Badge } from '@shared/components/ui/badge'
const fields: FieldDefinition[] = [
{ key: 'id', type: 'text', label: 'ID', hideInForm: true },
{ key: 'name', type: 'text', label: 'Full name', required: true, group: 'Person' },
{ key: 'email', type: 'email', label: 'Email', required: true, group: 'Person' },
{ key: 'seats', type: 'number', label: 'Seats', required: true, group: 'Plan', validations: { min: 1, max: 50 } },
{ key: 'notes', type: 'textarea', label: 'Notes', hideInTable: true, group: 'Plan' },
]
const { fieldList, formFields, tableColumns, fieldGroups, zodSchema } = useSchema(fields)
const valid = zodSchema.value.safeParse({ name: 'Amina Haddad', email: 'amina@example.com', seats: 12 })
const invalid = zodSchema.value.safeParse({ name: '', email: 'not-an-email', seats: 99 })
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ fieldList.length }} fields
</Badge>
<Badge variant="outline">
{{ formFields.length }} inputs
</Badge>
<Badge variant="outline">
{{ tableColumns.length }} columns
</Badge>
<Badge variant="outline">
{{ fieldGroups.length }} groups
</Badge>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<section
v-for="group in fieldGroups"
:key="group.name ?? 'ungrouped'"
class="rounded-lg border border-border p-3"
>
<h4 class="sa-text-sm font-semibold">
{{ group.name ?? 'Ungrouped' }}
</h4>
<ul class="mt-1 sa-text-sm text-muted-foreground">
<li v-for="field in group.fields" :key="field.key">
{{ field.label }}
</li>
</ul>
</section>
</div>
<div class="rounded-lg border border-border p-3 sa-text-sm">
<p>Complete row parses: <strong>{{ valid.success }}</strong></p>
<p>Empty name, bad email, 99 seats parses: <strong>{{ invalid.success }}</strong></p>
<ul v-if="!invalid.success" class="mt-1 text-muted-foreground">
<li v-for="issue in invalid.error.issues" :key="issue.path.join('.')">
{{ issue.path.join('.') }} — {{ issue.message }}
</li>
</ul>
</div>
</div>
</template>Import
ts
import { useSchema } from '@core/schema/useSchema'Signature
ts
function useSchema(fields: MaybeRefOrGetter<FieldDefinition[]>): {
fieldList: ComputedRef<FieldDefinition[]>
zodSchema: ComputedRef<ZodObject>
formFields: ComputedRef<FieldDefinition[]>
tableColumns: ComputedRef<FieldDefinition[]>
fieldGroups: ComputedRef<FieldGroup[]>
}fields is a MaybeRefOrGetter, so a plain array, a ref or a getter all work. A getter is what makes a schema that changes with the language or a permission stay live.
Returns
| Member | Type | Description |
|---|---|---|
fieldList | ComputedRef<FieldDefinition[]> | Everything you passed, unfiltered. |
formFields | ComputedRef<FieldDefinition[]> | Without hideInForm fields. |
tableColumns | ComputedRef<FieldDefinition[]> | Without hideInTable fields. |
zodSchema | ComputedRef<ZodObject> | Generated from formFields only. |
fieldGroups | ComputedRef<FieldGroup[]> | formFields bucketed by group, in first-seen order. |
FieldGroup
| Key | Type | Description |
|---|---|---|
name | string | undefined | The section or tab name. undefined means ungrouped, and renders no heading. |
fields | FieldDefinition[] | The fields in that group. |
Example
vue
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import { useSchema } from '@core/schema/useSchema'
const fields: FieldDefinition[] = [
{ key: 'id', type: 'text', label: 'ID', hideInForm: true },
{ key: 'name', type: 'text', label: 'Full name', required: true, group: 'Person' },
{ key: 'email', type: 'email', label: 'Email', required: true, group: 'Person' },
{ key: 'notes', type: 'textarea', label: 'Notes', hideInTable: true, group: 'Extra' },
]
const { formFields, tableColumns, fieldGroups, zodSchema } = useSchema(fields)
const result = zodSchema.value.safeParse({ name: 'Amina Haddad', email: 'amina@example.com' })
</script>
<template>
<div class="space-y-3">
<p>{{ formFields.length }} inputs, {{ tableColumns.length }} columns</p>
<section v-for="group in fieldGroups" :key="group.name ?? 'ungrouped'">
<h3 v-if="group.name">
{{ group.name }}
</h3>
<ul>
<li v-for="field in group.fields" :key="field.key">
{{ field.label }}
</li>
</ul>
</section>
<p>Valid: {{ result.success }}</p>
</div>
</template>Notes
- The policy of validating only what the form renders lives here, not in the generator.
generateZodSchemais pure and filters nothing;useSchemahands itformFields. That is why a field hidden from the form can never block a submit. tableColumnskeeps fields that are hidden in the form. A column can exist without an input — a created-at stamp is the usual case.- Grouping runs over
formFields, so a field hidden from the form cannot create an empty section. - Groups come out in the order their first field appears, not alphabetically, so the schema's order is the page's order.
- Everything is a
computed. Change the ref or the getter and the form, the table and the validation all follow in the same tick.