brumate

TypeScript

brumate-tsc type-checks a project with its .brm files, and the TypeScript plugin gives editors the same types. A .ts file that imports a component gets the type of its props.

Checking a project

pnpm add -D @brumate/tsc
brumate-tsc --noEmit
{
  "compilerOptions": {
    "types": ["brumate/types"]
  }
}

A file with a syntax error is reported with the code BRM. Its scripts are still checked.

In an editor

In VS Code, the extension adds the plugin itself.

In another editor, add the plugin to tsconfig.json, and use the TypeScript version of the workspace:

{
  "compilerOptions": {
    "plugins": [{ "name": "@brumate/typescript-plugin" }]
  }
}

What is checked

<script>
  import { $signal } from 'brumate/signals';

  let format = $signal((value: number) => String(value));
  // error: A function cannot be sent to the client. Keep it out of the signal or memo
</script>

Values that only the server reads are not checked, because they are not sent.

Props types

Other files need the props type of a component, such as a wrapper that passes props on, a story or a test. A component exports it from <script module>, and its instance script uses it:

<script module>
  export interface BadgeProps {
    label: string;
    isDisabled?: boolean;
  }
</script>

<script>
  import { $props } from 'brumate/signals';

  const { label, isDisabled } = $props<BadgeProps>();
</script>

Any other file imports it by name, as a type:

import type { BadgeProps } from './Badge.brm';

const props: BadgeProps = { label: 'New', isDisabled: 'yes' };
// error: Type 'string' is not assignable to type 'boolean | undefined'

Extending the props of another component

A wrapper that adds props to a component uses an intersection, since an interface cannot extend a type that depends on a parameter:

<script module>
  import type { ButtonProps } from '@brumate/ui/Button.brm';

  export type FancyProps = ButtonProps<'button'> & { tone?: 'red' | 'blue' };
</script>

<script>
  import { $props } from 'brumate/signals';
  import Button from '@brumate/ui/Button.brm';

  const { tone, children, ...rest } = $props<FancyProps>();
</script>

<Button data-tone=(tone) {...rest}>{children}</Button>

<Fancy tone="green" /> is an error, and so is a prop that a Button does not take.

Generic components

generics on the instance script declares the type parameter, and the props type takes it:

<script module>
  export interface ListProps<T> {
    items: T[];
    label: (item: T) => string;
  }
</script>

<script generics="T">
  import { $props } from 'brumate/signals';

  const { items, label } = $props<ListProps<T>>();
</script>

A caller infers T from its props. In <List items=([1, 2]) label=((n) => n.length) />, n is a number, so length is an error.

The as of @brumate/ui

A part that renders one element takes as, the name of an element or a component. Its other props are those of the target, so <Button as="a" href="/docs"> is valid, and <Button as="a" foo> is an error. With as=(Link), the props are those of Link.

The props type takes the target, ButtonProps<'a'> or ButtonProps<typeof Link>, and ButtonProps alone is a button. ButtonOwnProps has the props of the part without the target. To write a part like this, use AsProps of @brumate/ui/as.ts:

<script module>
  import type { AsProps, AsTarget } from '@brumate/ui/as.ts';

  export interface CardOwnProps {
    isFeatured?: boolean;
    children?: unknown;
  }
  export type CardProps<T extends AsTarget = 'div'> = AsProps<T, CardOwnProps>;
</script>

<script generics="T extends AsTarget = 'div'">
  import { $props } from 'brumate/signals';

  const { as = 'div', isFeatured, children, ...rest } = $props<CardProps<T>>();
</script>

<{as} data-featured=(isFeatured || undefined) {...rest}>{children}</>

Inside the part, a prop that depends on the target has a type that TypeScript cannot resolve. A value of it that goes to $memo needs a cast, such as $memo(as as AsTarget). If the part reads many props with exact types, read them from a call of $props<CardOwnProps>(), and as from another call of $props<CardProps<T>>().

Not checked yet

TypeScript 7

TypeScript 7 does not load plugins and has no API for other file types yet. So: