Typed components

How a component's source narrows to one resource's record, and createResourceComponents, the factory that binds a resource into every field, input and view.

A source is a path into a record. What the path may be depends on which record the component reads, and a plain component does not know. This page covers the two ways to tell it: leave it on the registered union, or bind one resource with createResourceComponents.

It assumes the app has registered its resources. Each example below hides that setup above a ---cut--- line and shows the call site.

Plain components

A component you import from the package takes every registered resource's paths:

src/posts.tsx
import { TextInput } from "@chelone/react";
 
export const title = <TextInput source="title" />;

Its source is AnySourcePath: the union of the paths of every registered resource. A path that no resource has is an error, and a path that any resource has compiles, even on a page about another one. AnySourcePath is string when nothing is registered, and also when any registered resource has no schema, since that resource's record names no paths.

To check a source against one resource, bind it.

createResourceComponents("posts")

createResourceComponents takes a resource name and hands back that resource's components. It takes no type arguments: the name is checked against the registry and the record is read from it. Call it at module scope.

src/posts.tsx
import { createResourceComponents } from "@chelone/react";
 
const posts = createResourceComponents("posts");
 
export const PostEdit = () => (
  <posts.Edit id={1}>
    <posts.TextInput source="title" />
    <posts.NumberInput source="views" />
  </posts.Edit>
);

In React a second call with the same name returns the same components, so a call inside a component body does not remount them. It is still wasted work, so keep it at module scope.

What it returns

The type is ResourceComponents, and it has two parts:

  • The package's own fields and inputs, retyped and not wrapped. posts.TextInput === TextInput holds at runtime. Only the type of source, and of a few callbacks below, changes.
  • The four views posts.Edit, posts.Create, posts.Show and posts.List, bound to the name. They take no resource prop, because the factory passes "posts" down.

The fields and inputs that are members include posts.TextField, posts.NumberField, posts.DateField, posts.TextInput, posts.NumberInput and posts.SelectArrayInput. The list is read from the source: React reads it from the components object in create-resource-components.tsx, and Vue from sourceMembers plus TypedFieldMembers and TypedInputMembers. A new field or input that takes a source joins the list in both adapters.

How source narrows

Each member's source becomes SourceProp of that resource's record. Dot paths work, and so do bracket paths into an array, such as tags[0].label. The ^? line shows what a post's source takes: its paths, the row paths of its tags (label), and filter keys such as q:

src/posts.tsx
const 
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
            views: z.ZodNumber;
            tags: z.ZodArray<z.ZodObject<{
                label: z.ZodString;
            }, z.core.$strip>>;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
=
createResourceComponents<"posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
            views: z.ZodNumber;
            tags: z.ZodArray<z.ZodObject<{
                label: z.ZodString;
            }, z.core.$strip>>;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("posts");
type
type PostSource = "id" | "title" | "views" | "tags" | "label" | `tags[${number}]` | `tags[${number}].label` | FilterSourceKey<{
    id: number;
    title: string;
    views: number;
    tags: {
        label: string;
    }[];
}>
PostSource
= type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never
Obtain the parameters of a function type in a tuple
Parameters
<typeof
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
            views: z.ZodNumber;
            tags: z.ZodArray<z.ZodObject<{
                label: z.ZodString;
            }, z.core.$strip>>;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
.
type TextInput: <TPath extends _RecordSourcePath<{
    id: number;
    title: string;
    views: number;
    tags: {
        label: string;
    }[];
}>>(props: Omit<InputProps<unknown, "id" | "title" | "views" | "tags" | "label" | "body" | "q" | `tags[${number}]` | `tags[${number}].label` | `id_${string}` | `title_${string}` | `views_${string}` | `tags_${string}` | `tags[${number}]_${string}` | `tags[${number}].label_${string}` | `body_${string}`> & {
    multiline?: boolean;
    inputClassName?: string;
    resettable?: boolean;
    className?: string;
    controlSuffix?: React.ReactNode;
} & Omit<DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "defaultValue" | ... 2 more ... | "type"> & Omit<...>, "transform" | ... 3 more ... | "parse"> & {
    ...;
} & {
    ...;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
TextInput
>[0]["source"];

A misspelt path is an error:

src/posts.tsx
const 
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
=
createResourceComponents<"posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("posts");
const const field: JSX.Elementfield = <
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
.
type TextInput: <TPath extends _RecordSourcePath<{
    id: number;
    title: string;
}>>(props: Omit<InputProps<unknown, "id" | "title" | "body" | "q" | `id_${string}` | `title_${string}` | `body_${string}`> & {
    multiline?: boolean;
    inputClassName?: string;
    resettable?: boolean;
    className?: string;
    controlSuffix?: React.ReactNode;
} & Omit<DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "defaultValue" | "onBlur" | "onChange" | "type"> & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "defaultValue" | ... 2 more ... | "type">, "defaultValue" | ... 3 more ... | "transform"> & {
    ...;
} & {
    ...;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
TextInput
source="titel" />;
Type '"titel"' is not assignable to type '_RecordSourcePath<{ id: number; title: string; }>'. Did you mean '"title"'?

So is a path that only another resource has. body is a path of comments, and a plain <TextInput source="body" /> accepts it on a posts page. The bound posts.TextInput does not:

src/posts.tsx
const 
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
=
createResourceComponents<"posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("posts");
const const field: JSX.Elementfield = <
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
    readonly comments: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            body: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
.
type TextInput: <TPath extends _RecordSourcePath<{
    id: number;
    title: string;
}>>(props: Omit<InputProps<unknown, "id" | "title" | "body" | "q" | `id_${string}` | `title_${string}` | `body_${string}`> & {
    multiline?: boolean;
    inputClassName?: string;
    resettable?: boolean;
    className?: string;
    controlSuffix?: React.ReactNode;
} & Omit<DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "defaultValue" | "onBlur" | "onChange" | "type"> & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "defaultValue" | ... 2 more ... | "type">, "defaultValue" | ... 3 more ... | "transform"> & {
    ...;
} & {
    ...;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
TextInput
source="body" />;
Type '"body"' is not assignable to type '_RecordSourcePath<{ id: number; title: string; }>'.

A source that is optional on a component stays optional on the member. In React, ReferenceManyCount declares source?, so <posts.ReferenceManyCount reference="comments" target="post_id" /> compiles, while <posts.TextInput /> does not.

Value callbacks

format, parse, transform and defaultValue take the value at that source, which is SourceValue of the record and the path. On a views source that is a number:

src/posts.tsx
const 
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            views: z.ZodNumber;
        }, z.core.$strip>;
    };
}, "posts">
posts
=
createResourceComponents<"posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            views: z.ZodNumber;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("posts");
export const const views: JSX.Elementviews = ( <
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            views: z.ZodNumber;
        }, z.core.$strip>;
    };
}, "posts">
posts
.
type NumberInput: <TPath extends _RecordSourcePath<{
    id: number;
    views: number;
}>>(props: Omit<NumberInputProps<unknown, _RecordSourcePath<{
    id: number;
    views: number;
}>>, "defaultValue" | "source" | "format" | "parse" | "transform"> & {
    source: TPath;
} & {
    defaultValue?: NoInfer<string extends TPath ? unknown : _Dotted<TPath> extends `${infer H}.${infer R}` ? _GetPath<_Index<{
        id: number;
        views: number;
    }, H>, R> : _Index<{
        id: number;
        views: number;
    }, _Dotted<TPath>>> | undefined;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
NumberInput
source: "views"source="views" format?: ((value: number) => string) | undefinedformat={(
value: number
value
) =>
var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
(value: numbervalue)}
/> );

A parse must return that value type, so parse={(value) => Number(value)} is an error on a title source. A member whose component has none of the four props keeps its own props.

Not members

The add-on components, which are monaco, maps, mdx, supabase and csv-import, are not members. The import fence keeps them out of the main entry, so the factory cannot hand them back. Import them from their own entry. Their source keeps the registered AnySourcePath.

With no registration

When Register is not augmented, any name compiles and every source is a string:

src/posts.tsx
import { function createResourceComponents<TName extends RegisteredResourceName>(name: TName): ResourceComponents<RegisteredResources, TName>
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
} from "@chelone/react";
const const comments: ResourceComponents<ResourceMap, "comments">comments = createResourceComponents<"comments">(name: "comments"): ResourceComponents<ResourceMap, "comments">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("comments");
const const field: JSX.Elementfield = <const comments: ResourceComponents<ResourceMap, "comments">comments.
type TextInput: <TPath extends string>(props: Omit<InputProps<unknown, string> & {
    multiline?: boolean;
    inputClassName?: string;
    resettable?: boolean;
    className?: string;
    controlSuffix?: React.ReactNode;
} & Omit<DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "defaultValue" | "onBlur" | "onChange" | "type"> & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "defaultValue" | ... 2 more ... | "type">, "defaultValue" | ... 3 more ... | "transform"> & {
    ...;
} & {
    ...;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
TextInput
source: "anything"source="anything" />;

The same code stays valid when the app registers later. What changes is that "comments" has to be a registered name.

With comments missing from a registry that has posts only, the name is the error:

src/posts.tsx
const 
const comments: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
comments
=
createResourceComponents<"posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the registered map. With nothing registered every name compiles and every `source` is a `string`.
@exampleconst posts = createResourceComponents("posts"); <posts.TextInput source="title" />
createResourceComponents
("comments");
Argument of type '"comments"' is not assignable to parameter of type '"posts"'.

createResourceComponentsOf

createResourceComponentsOf<typeof resources, "posts">("posts") writes both type arguments, so it needs no Register augmentation. Register is global to a TypeScript program, so this suits a type test or a program that must not augment it. The returned type is the same ResourceComponents.

src/posts.test-d.tsx
import { function defineResources<const T extends ResourceMap>(resources: T & Checked<T>): T
Identity helper that pins literal resource names + schema types via `const`. `defineResources({ posts: { schema: postSchema } })` keeps `"posts"` literal and `typeof postSchema` exact, enabling end-to-end inference.
defineResources
} from "@chelone/core";
import { function createResourceComponentsOf<TResources extends ResourceMap, TName extends keyof TResources & string>(name: TName): ResourceComponents<TResources, TName>
The components of the resource `name`, typed by the record of that resource in the map `TResources`. Use it where `Register` is not augmented, as a type test does.
@exampleconst posts = createResourceComponentsOf<typeof resources, "posts">("posts");
createResourceComponentsOf
} from "@chelone/react";
import { import zz } from "zod"; const
const resources: {
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}
resources
=
defineResources<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}>(resources: {
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
} & Checked<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}>): {
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}
Identity helper that pins literal resource names + schema types via `const`. `defineResources({ posts: { schema: postSchema } })` keeps `"posts"` literal and `typeof postSchema` exact, enabling end-to-end inference.
defineResources
({
posts: {
    readonly schema: z.ZodObject<{
        id: z.ZodNumber;
        title: z.ZodString;
    }, z.core.$strip>;
} & {
    references?: {} | undefined;
}
posts
: {
schema: z.ZodObject<{
    id: z.ZodNumber;
    title: z.ZodString;
}, z.core.$strip>
schema
: import zz.
function object<{
    id: z.ZodNumber;
    title: z.ZodString;
}>(shape?: {
    id: z.ZodNumber;
    title: z.ZodString;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    id: z.ZodNumber;
    title: z.ZodString;
}, z.core.$strip>
object
({ id: z.ZodNumberid: import zz.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumbernumber(), title: z.ZodStringtitle: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }) },
}); const
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
=
createResourceComponentsOf<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">(name: "posts"): ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
The components of the resource `name`, typed by the record of that resource in the map `TResources`. Use it where `Register` is not augmented, as a type test does.
@exampleconst posts = createResourceComponentsOf<typeof resources, "posts">("posts");
createResourceComponentsOf
<typeof
const resources: {
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}
resources
, "posts">("posts");
export const const title: JSX.Elementtitle = <
const posts: ResourceComponents<{
    readonly posts: {
        readonly schema: z.ZodObject<{
            id: z.ZodNumber;
            title: z.ZodString;
        }, z.core.$strip>;
    };
}, "posts">
posts
.
type TextInput: <TPath extends _RecordSourcePath<{
    id: number;
    title: string;
}>>(props: Omit<InputProps<unknown, string> & {
    multiline?: boolean;
    inputClassName?: string;
    resettable?: boolean;
    className?: string;
    controlSuffix?: React.ReactNode;
} & Omit<DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "defaultValue" | "onBlur" | "onChange" | "type"> & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "defaultValue" | ... 2 more ... | "type">, "defaultValue" | ... 3 more ... | "transform"> & {
    ...;
} & {
    ...;
} & {
    ...;
} & {
    ...;
}) => JSX.Element
TextInput
source: "title"source="title" />;

Vue differences

createResourceComponents and createResourceComponentsOf take the same arguments in @chelone/vue, and posts.TextInput is the package's own component there too. Two things differ:

  • The views keep resource as never, so <posts.Edit resource="users"> is a type error in a template under vue-tsc. React leaves the prop out.
  • A generic SFC that TypedFieldMembers and TypedInputMembers do not list is not a member. RecordField and SelectInput are such components. Import them directly. Their source is on the registered union, as for a plain component.
src/PostForm.vue
<script setup lang="ts">
import { RecordField, SelectInput, createResourceComponents } from "@chelone/vue";
 
const posts = createResourceComponents("posts");
</script>
 
<template>
  <posts.Edit :id="1">
    <!-- A member: source is checked against posts. -->
    <posts.TextInput source="title" />
    <!-- Not members: source is any registered path. -->
    <SelectInput source="status" :choices="[]" />
    <RecordField source="author" />
    <!-- Error: the view is bound to posts. -->
    <posts.Edit resource="users" />
  </posts.Edit>
</template>