Types and generics
Types let a component tell the people who use it what it accepts, and let their editor catch a mistake before anyone runs the page. A design system keeps them apart from the components: a ButtonProps in a shared file, a Tone that several components read, a list typed by the items it's given. Think of a props interface copied into every component that needs it until two copies disagree, a list whose selected item can be anything at all, or a consumer who passes tone="danger" to a button that has no such tone and only finds out in the browser.
This page walks you through the types Unframework reads and what each framework's output does with them. We'll look at:
- Props types from other files: a
.tsmodule, a child's module or a package. - What the compiler reads:
extends, intersections, the five utility types, and what stays out. - Generic components:
function List<T>, in each framework's own generic form. - Exported types: what a consumer imports, and how it's checked.
Props types from other files
A component's props type can come from a relative .ts module, as a design system declares them:
// The props of `Badge`, declared in a module of their own, as a design system declares them.
export interface BadgeProps {
/** The text the badge shows. */
label: string;
tone?: "info" | "danger";
}
import type { BadgeProps } from "./types";
export default function Badge({ label, tone = "info" }: BadgeProps) {
return <span class={`badge badge-${tone}`}>{label}</span>;
}
The compiler reads the members through its type oracle, so it knows that label is a required string and tone an optional literal union with a default. Each output imports BadgeProps by name, rather than printing its members again, so your editor shows the type you wrote, with its comments:
import type { BadgeProps } from "./types";
import type { CSSProperties } from "react";
export interface BadgeAttributes {
className?: string;
style?: CSSProperties;
}
export default function Badge({
label,
tone = "info",
className,
style,
...attributes
}: BadgeProps & BadgeAttributes) {
return (
<span
className={cx(`badge badge-${tone}`, className)}
style={style}
{...scopeAttributes(attributes)}
>
{label}
</span>
);
}
/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
const names: string[] = [];
for (const part of parts) {
if (typeof part === "string") {
if (part) names.push(part);
} else if (part && typeof part === "object") {
for (const [key, on] of Object.entries(part)) {
if (on) names.push(key);
}
}
}
return names.join(" ");
}
/** The scope attributes of the styled parents that render this component, for its root. */
function scopeAttributes(props: object): Record<string, ""> {
const scope: Record<string, ""> = {};
for (const key of Object.keys(props)) {
if (key.startsWith("data-uf-")) scope[key] = "";
}
return scope;
}
Each output tree also holds types.ts, copied unchanged, so every output checks and loads on its own. Angular declares each input on its own, typed through the interface: BadgeProps["label"].
A type can come from a child's module too. Here a form's prop is typed with the props its child exports:
import Field from "./Field.uf.tsx";
import type { FieldProps } from "./Field.uf.tsx";
// A props type a child's module exports, imported from the child (ADR-0067).
export default function Form({ field }: { field: FieldProps }) {
return (
<form aria-label="Sign up">
<Field name={field.name} label={field.label} />
</form>
);
}
And from a package, through its types and exports, as TypeScript's bundler resolution finds them. value and interval come from rxjs's generic TimeInterval<T>, and label from a local module:
import type { TimeInterval } from "rxjs";
import type { Labelled } from "./types";
// Members a package declares, `value` and `interval` from rxjs's generic `TimeInterval<T>`, beside
// one a `.ts` module declares.
type StatusProps = TimeInterval<string> & Labelled;
export default function Status({ value, interval, label }: StatusProps) {
return (
<p>
{label}: {value} after {interval} ms
</p>
);
}
What the compiler reads
The compiler reads a props type as written, without running a type checker, so the same source always gives the same props. It follows:
- interfaces and type aliases, local or imported, and their re-exports;
extendschains, across modules (types/extends-chain);- intersections of interfaces and type literals (
types/intersection-props); - unions and literal unions;
- generic declarations, instantiated with their arguments;
- the five utility types:
Partial,Required,Pick,OmitandReadonly.
For example, the props of a text field can pick what they require from a looser interface:
// Every option of a field is optional: the component's props pick the ones it requires.
export interface FieldOptions {
name?: string;
label?: string;
placeholder?: string;
required?: boolean;
}
import type { FieldOptions } from "./types";
// The five utility types of plan §5.5, over an imported interface.
export type TextFieldProps = Required<Pick<FieldOptions, "name" | "label">> &
Readonly<Partial<Omit<FieldOptions, "name" | "label">>>;
export default function TextField({ name, label, placeholder, required }: TextFieldProps) {
return (
<label>
{label}
<input name={name} placeholder={placeholder} required={required} />
</label>
);
}
name and label are required, and placeholder and required optional and read-only. Vue's compiler resolves that alias by itself, so Vue's output keeps it as written:
<script setup lang="ts">
import type { FieldOptions } from "./types";
export type TextFieldProps = Required<Pick<FieldOptions, "name" | "label">> &
Readonly<Partial<Omit<FieldOptions, "name" | "label">>>;
const {
name,
label,
placeholder = undefined,
required = undefined,
} = defineProps<TextFieldProps>();
</script>
<template>
<label>{{ label }}<input :name="name" :placeholder="placeholder" :required="required" /></label>
</template>
Where Vue's compiler can't resolve a construct, such as a type from a package, Vue's output prints one member for each prop, each typed through the original:
<script setup lang="ts">
import type { Labelled } from "./types";
import type { TimeInterval } from "rxjs";
type StatusProps = TimeInterval<string> & Labelled;
const { value, interval, label } = defineProps<{
value: StatusProps["value"];
interval: StatusProps["interval"];
label: StatusProps["label"];
}>();
</script>
<template>
<p>{{ label }}: {{ value }} after {{ interval }} ms</p>
</template>
Every other output keeps StatusProps and imports what it names.
What stays out
A props type the compiler can't read for every framework is a diagnostic at its span, never a guess:
- UF6001: the type names a declaration the compiler can't find, such as a typo, a missing file or a package without types.
- UF6002: conditional and mapped types,
infer,keyof,typeofand template-literal types, which need a type checker. - UF6003: the type is declared in another module without
export, so no output can import it. - UF6004: the props type isn't an object type, such as a union of two object types.
- UF6005: an intersection or an
extendschain declares one prop twice, with two types.
For UF6002, compile() takes a second type oracle, createTsgoOracle({ root }) from @unframework/type-oracle-tsgo, which hands those constructs to TypeScript's own checker. The Vite plugin always uses the default one.
Generic components
A list, a select or a table is often generic over its items. Declare the type parameter on the component's function, and let its props take it:
// A component generic over its items (ADR-0068): `T` is what its consumer's `items` holds.
export interface ListProps<T> {
label: string;
items: T[];
selected?: T;
}
export default function List<T>({ label, items, selected }: ListProps<T>) {
return (
<ul aria-label={label}>
{items.map((item, index) => (
<li key={index} aria-current={item === selected ? "true" : undefined}>
{String(item)}
</li>
))}
</ul>
);
}
A consumer never names T: it comes from the items they pass, so selected must be one of them. Each framework writes it in its own generic form:
import type { CSSProperties } from "react";
export interface ListProps<T> {
label: string;
items: T[];
selected?: T;
}
export interface ListAttributes {
className?: string;
style?: CSSProperties;
}
export default function List<T>({
label,
items,
selected,
className,
style,
...attributes
}: ListProps<T> & ListAttributes) {
return (
<ul aria-label={label} className={className} style={style} {...scopeAttributes(attributes)}>
{items.map((item, index) => (
<li key={index} aria-current={item === selected ? "true" : undefined}>
{String(item)}
</li>
))}
</ul>
);
}
/** The scope attributes of the styled parents that render this component, for its root. */
function scopeAttributes(props: object): Record<string, ""> {
const scope: Record<string, ""> = {};
for (const key of Object.keys(props)) {
if (key.startsWith("data-uf-")) scope[key] = "";
}
return scope;
}
| Target | Generic form |
|---|---|
| React, Solid | a generic function component |
| Vue | <script setup lang="ts" generic="T"> |
| Svelte | <script lang="ts" generics="T"> |
| Angular | a generic component class, whose T Angular infers from inputs |
| Qwik | a generic arrow inside component$, <T,>(…) => …, with no cast |
| Astro | interface Props<T> |
A type parameter can take a constraint, function Table<T extends { id: string; label: string }> (generics/constrained). Explicit type arguments on a tag, <List<string> … />, are UF6006, and so is a generic component as a candidate of <component is>, since several frameworks' templates have no form for them.
Scoped slots on Angular
A scoped slot's props can name the type parameter too, so a consumer's fill reads their own item type:
import { defineSlots } from "unframework";
import type { Element } from "unframework";
// A scoped slot whose props name the type parameter (ADR-0068): a consumer's fill reads `T`.
export interface PickerProps<T> {
items: T[];
}
export default function Picker<T>({ items }: PickerProps<T>) {
const slots = defineSlots<{ item?(props: { item: T }): Element }>();
return (
<ul>
{items.map((item, index) => (
<li key={index}>{slots.item?.({ item })}</li>
))}
</ul>
);
}
React, Vue, Svelte, Solid, Qwik and Astro type the fill's item by the type argument the consumer's items give. Angular infers a component's type parameter from its inputs alone, and a slot's directive can't infer it, so the slot's context takes the parameter's constraint, and an unconstrained T reads as any:
export type PickerItemContext = { item: any };
The component renders the same. Only a consumer's type checking differs, so the compiler says so for information, with UF4004 at the slot (generic-slot-props on the targets page). Give T a constraint that names what consumers read, or accept the looser type on Angular.
Exported types
Every type a component's module exports, each output exports under the same name: its props type, a type the props reach, and one that no component uses at all:
// Every type the module exports is exported by each output (ADR-0068): one the props reach, and
// one no component reaches.
export type ButtonTone = "primary" | "secondary";
export type ButtonSize = "small" | "large";
export interface ButtonProps {
label: string;
tone?: ButtonTone;
}
export default function Button({ label, tone = "primary" }: ButtonProps) {
return (
<button type="button" class={`button button-${tone}`}>
{label}
</button>
);
}
React, Solid, Qwik and Angular export them from the module, Vue from <script setup>, Astro from its frontmatter, and Svelte from <script module>:
<svelte:options runes={true} preserveWhitespace={false} />
<script lang="ts" module>
export type ButtonTone = "primary" | "secondary";
export type ButtonSize = "small" | "large";
export interface ButtonProps {
label: string;
tone?: ButtonTone;
}
</script>
<script lang="ts">
import type { ClassValue } from "svelte/elements";
type Props = ButtonProps & { class?: ClassValue; style?: string };
let { label, tone = "primary", class: className, style, ...attributes }: Props = $props();
/** The scope attributes of the styled parents that render this component, for its root. */
function scopeAttributes(props: object): Record<string, ""> {
const scope: Record<string, ""> = {};
for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = "";
return scope;
}
</script>
<button
type="button"
class={[`button button-${tone}`, className]}
{style}
{...scopeAttributes(attributes)}
>{label}</button>
So a consumer imports them as they would from any component written in their framework, and their checker reports a misuse. The tests type-check a consumer in each framework, like this React one, which must fail at the two lines its comments name:
import Button, {
type ButtonProps,
type ButtonSize,
} from "../../../../../integration/cases/types/exported-types/__output__/react/Button";
export function MisuseType() {
// @uf-expect TS2322 Button.type:ButtonProps
const props: ButtonProps = { label: "Save", tone: "danger" };
// @uf-expect TS2322 Button.type:ButtonSize
const size: ButtonSize = "huge";
return <Button label={props.label} data-size={size} />;
}
A module can re-export a type it imports, so its consumers find it beside the component:
import Swatch from "./Swatch.uf.tsx";
import type { Tone } from "./types";
// Consumers of `Palette` import the shared type from it.
export type { Tone } from "./types";
export default function Palette({ tone }: { tone: Tone }) {
return (
<div class="palette">
<Swatch tone={tone} />
</div>
);
}
To read a component's full props, attributes included, use the framework's own utility:
| Target | Its props |
|---|---|
| React, Solid, Svelte | ComponentProps<typeof Button> |
| Vue | InstanceType<typeof Button>["$props"] |
| Qwik | PropsOf<typeof Button> |
| Astro | ComponentProps<typeof Button>, from astro/types |
Angular has no such utility for a component's inputs, so an Angular consumer imports the exported props type, ButtonProps, instead.
The …Events, …Models and …Slots interfaces that an output exports beside its component, such as FieldEvents on Child components, stay as they are.
The decisions behind this page are ADR-0067 and ADR-0068. Values shared between components, rather than types, are on Shared modules.
Styling engines
A styling engine works through the modules its own Vite plugin registers, or the files it generates. Its imports reach every output as written, and its plugin resolves them. Styleframe is the worked example.
Composables
A composable is reactive logic that several components share, written once in a .uf.ts module. Each framework gets it as its own idiom, a React hook, a Svelte rune module, an Angular function, …, so consumers can call it from components they write by hand.