Importing XML components with type safety

The RSG 2.0 SDK provides compile-time TypeScript type safety for BrightScript XML component imports. When you import an .xml component file, the build system automatically generates .d.ts type declaration files that reflect the component's <interface> field definitions. The following example imports an XML component:

import MyButton from './components/Button.xml';
// MyButton is TypedXmlComponent<"Button", { text: string; width: number; enabled: boolean }>
// TypeScript provides autocomplete and type checking for all component fields

Once imported, these components can then be used easily in your TypeScript programs. The imported component may come from your existing package directory, or it may be a new component under development to help interface with the BrightScript render thread, for example.

Understanding XML component type generation

The build system includes the following features that enable seamless integration between your XML markup and TypeScript code:

  • Type-safe XML imports: Import XML components in TypeScript with type checking.
  • Automatic type generation: The build generates .d.ts files automatically from XML <interface> definitions.
  • BrightScript to TypeScript type mapping: Automatic conversion of BrightScript types to TypeScript equivalents.
  • JSX IntelliSense: Autocomplete and type checking when you use components in JSX.
  • Base props by extends: The generated props interface resolves from XML extends using component mapping.
  • Private field filtering: Only public interface fields appear in generated types.
  • Watch mode support: Types automatically regenerate when XML files change.
  • Automatic BrightScript inclusion: The build automatically includes BrightScript files that XML <script> tags reference.

This feature is enabled by default for all XML imports. The generated .d.ts files are build artifacts and should not be checked into source control.

Understanding how type generation works

When the build system encounters an XML import, it does the following:

  1. Parses the <interface> section from the XML file
  2. Extracts all <field> definitions with their types
  3. Maps BrightScript types to TypeScript equivalents
  4. Generates a .d.ts file alongside the XML file
  5. Adds entries to JSX IntrinsicElements for SolidJS usage

Generated type declaration files

The build generates a corresponding .xml.d.ts file for each imported XML component. The following example demonstrates how this works for the Button component.

Button.xml

The following XML file defines the Button component:

<component name="Button" extends="Group">
    <interface>
        <field id="text" type="string" />
        <field id="width" type="float" />
        <field id="enabled" type="boolean" value="true" />
    </interface>
    <script type="text/brightscript" uri="Button.brs" />
</component>

Generated Button.xml.d.ts

The build generates the following declaration file:

// Auto-generated TypeScript declarations for XML component import.
// DO NOT EDIT — this file is regenerated during build.
//
// Source component: Button
// Extends: Group

import type { TypedXmlComponent } from "@roku-sdk/interfaces";
import type { IGroupProps } from "@roku-sdk/components";

interface ButtonFields {
    text: string;
    /** @default "true" */
    enabled: boolean;
    width: number;
}

interface IButtonProps extends IGroupProps, Partial<ButtonFields> {}

declare const _default: TypedXmlComponent<"Button", ButtonFields>;
export default _default;

declare module "@roku-sdk/solid" {
    namespace JSX {
        interface IntrinsicElements {
            "button": IButtonProps;
        }
    }
}

Understanding base component mapping

The generated props interface extends the mapped base props for the XML component's extends value:

<component name="MyButton" extends="Rectangle">
    <interface>
        <field id="cornerRadius" type="float" />
    </interface>
</component>

The generated declaration uses IRectangleProps:

import type { IRectangleProps } from "@roku-sdk/components";
interface IMyButtonProps extends IRectangleProps, Partial<MyButtonFields> {}

Defaults and custom overrides

The SDK uses component mapping to resolve XML extends attributes to their corresponding TypeScript types. Component mappings are configured at multiple levels:

  • By default, the SDK provides a mapping for standard components from @roku-sdk/components (for example, Group, Rectangle, Label, Poster, LayoutGroup, ScrollingLabel, and so on).
  • The SDK defines the external and public defaults in rsg-sdk/external/packages/tooling/src/esbuild/default-component-mapping.external.ts.
  • Roku-internal builds define an additional mapping that combines external and internal-only components.
  • You can override or extend the effective mapping through top-level dtsOptions in roku-config.ts.

The following example overrides and extends the mapping:

import type { RokuConfig } from "@roku-sdk/rsg-ts/roku-config";

const config: RokuConfig = {
    app: {
        entryPoint: "src/index.ts"
    },
    dtsOptions: {
        componentMapping: {
            MyCustomBase: {
                interface: "IMyCustomBaseProps",
                module: "./src/components/MyCustomBase.xml"
            },
            Group: {
                interface: "ICustomGroupProps",
                module: "./src/components/CustomGroup.xml"
            }
        }
    }
};

export default config;

Understanding failure behavior for missing mappings

If a component extends a base class that is not in the merged mapping (defaults and overrides), the build fails with the following error:

XML component 'MyButton' extends 'CustomBase', but 'CustomBase' is not defined in dtsOptions.componentMapping.

For framework component modules (rsg-sdk/*/packages/components/src/xml-components/**), the error message instead instructs maintainers to update DEFAULT_COMPONENT_MAPPING in the framework mapping files.

📘

Important

The .xml.d.ts files are auto-generated build artifacts that must adhere to the following rules:

  • You must add them to .gitignore (the SDK already does this).
  • They must be regenerated on every build.
  • You must not edit them manually.
  • You must not check them into source control.

Comparing source and static directory imports

The build system handles XML imports differently depending on whether the XML file is located in the source directory (typical) or the static assets directory (for pre-built assets).

Source directory imports

You should use this import method for new TypeScript projects, which benefit from tree shaking and watch mode.

The following example demonstrates a typical XML file import from your TypeScript source directory:

// src/App.tsx
import MyButton from './components/Button.xml';  // Source import

Source directory import behavior

When you import an XML file from your TypeScript source directory, the following behavior applies:

  • The build copies the XML file to <output-directory>/components/Button.xml.
  • The build copies referenced BrightScript files (from <script> tags) to <output-directory>/components/.
  • The build generates the .d.ts file in the source directory (next to the original XML).
  • The build includes only imported components (tree shaking). See Asset tree-shaking for more information about how the build decides what to package and how package assets shake out when unused.

Directory structure

The following structure shows the source files, the generated declaration, and the build output:

src/
├── components/
│   ├── Button.xml              ← Source file
│   ├── Button.xml.d.ts         ← Generated (gitignored)
│   └── Button.brs              ← Source file
└── App.tsx                     ← Imports Button.xml

<output-directory>/
└── components/
    ├── Button.xml              ← Copied during build
    └── Button.brs              ← Copied during build

Static directory imports

You can use static directory imports for components from the pkg/ folder, which are in the traditional Roku directory structure.

The following example demonstrates an XML file import from the static assets directory (configured through staticAssetsDir in roku-config.ts):

// roku-config.ts
const config: RokuConfig = {
    app: {
        entryPoint: "src/index.ts",
        staticAssetsDir: "../pkg"  // Pre-built assets directory
    }
};

// src/App.tsx
import PackagedComponent from '../pkg/components/PackagedComponent.xml';  // Static import

Static directory behavior

The following behavior applies when you import XML files from the static assets directory:

  • The build does not copy the XML file (it is already in the static directory, which the build copies in its entirety during build setup).
  • The build does not copy the referenced BrightScript files (they already reside in the static directory).
  • The build generates the .d.ts file in the source location (next to the XML in pkg/).
  • Static imports bypass tree shaking, and the build includes the entire static directory.

Using basic imports

After the build generates the types, you can import and use XML components in your TypeScript code with type safety.

Importing XML components

You can import XML components like regular TypeScript modules to access both runtime values and compile-time type information:

import MyComponent from "./components/MyComponent.xml";

// The imported value is the component name as a string (runtime)
console.log(MyComponent); // "MyComponent"

// But TypeScript knows the full type information (compile-time)
type ComponentFields = typeof MyComponent extends TypedXmlComponent<any, infer F> ? F : never;

Using imports with JSX

The generated types automatically augment SolidJS JSX for IntelliSense, as the following example demonstrates:

import { createSignal } from "@roku-sdk/solid";
import "./components/Button.xml";  // Side-effect import augments JSX types

export const App = () => {
    const [label, setLabel] = createSignal("Click me");

    return (
        <button
            text={label()}           // TypeScript knows this is string
            showFocusFootprint={true} // TypeScript knows this is boolean
            width={200}              // TypeScript knows this is number
            nonExistent="oops"       // TypeScript error: unknown prop
        />
    );
};

Including BrightScript files automatically

The build automatically includes the BrightScript files that XML <script> tags reference, as the following example demonstrates:

<!-- Button.xml -->
<component name="Button" extends="Group">
    <script type="text/brightscript" uri="Button.brs" />
    <script type="text/brightscript" uri="helpers/utils.brs" />
</component>
import Button from './components/Button.xml';  // Imports XML only in TypeScript
// Button.brs and helpers/utils.brs are automatically included in the output

Automatic BrightScript file inclusion has the following effects:

  • You do not need to manually import or reference BrightScript files. The build system analyzes XML dependencies automatically.
  • For source directory imports, the build copies the BrightScript files to the flattened <output-directory>/components/ directory alongside the XML file, regardless of their original directory structure (for example, helpers/utils.brs becomes <output-directory>/components/utils.brs).
  • For static directory imports, the build does not copy BrightScript files because they already exist in the static directory.

Type safety examples

The generated type definitions provide compile-time validation for your XML components, catching errors before runtime.

Field type checking

TypeScript validates that JSX props match the types declared in your XML <interface>:

<interface>
    <field id="title" type="string" />
    <field id="count" type="integer" />
    <field id="visible" type="boolean" />
    <field id="data" type="assocarray" />
</interface>

The following JSX shows valid and invalid props:

<myComponent
    title="Hello"                    // string
    count={42}                       // number
    visible={true}                   // boolean
    data={{ key: "value" }}          // Record<string, unknown>

    title={123}                      // Error: Type 'number' is not assignable to type 'string'
    count="not a number"             // Error: Type 'string' is not assignable to type 'number'
/>

Default values

The build documents fields with default values in JSDoc comments:

<field id="enabled" type="boolean" value="true" />
<field id="maxWidth" type="float" value="1920" />

The build generates the following interface:

interface ComponentFields {
    /** @default "true" */
    enabled: boolean;
    /** @default "1920" */
    maxWidth: number;
}

Optional and required fields

The generated types treat all component fields as optional in JSX (they extend Partial<ComponentFields>):

// All valid - no required fields
<myComponent />
<myComponent title="Only title" />
<myComponent title="Title" count={5} visible={true} />

This matches Roku SceneGraph behavior where all interface fields have implicit defaults.

BrightScript to TypeScript type mapping

The type generator automatically converts BrightScript types from XML <interface> fields to their TypeScript equivalents:

BrightScript typeTypeScript type
stringstring
uristring
colorstring
integer, intnumber
float, doublenumber
longintegernumber
timenumber
boolean, boolboolean
nodeimport('@roku-sdk/interfaces').DomNode
array, roarrayunknown[]
stringarraystring[]
intarraynumber[]
floatarraynumber[]
boolarrayboolean[]
assocarray, roassociativearrayRecord<string, unknown>
vector2d[number, number]
rect2d{ x: number; y: number; width: number; height: number }

Unrecognized types map to unknown.

Understanding watch mode behavior

In development mode (rk dev), the type generation system does the following:

  1. Monitors XML files for changes
  2. Automatically regenerates .d.ts files when the XML interface changes
  3. Optimizes writes (it writes only when content changes)
  4. Updates TypeScript (tsserver picks up new types automatically)

Example watch flow

The following steps show how type generation responds to changes in real time during development:

  1. Edit Button.xml by adding a new field:

    <field id="cornerRadius" type="float" />
  2. Save the file. The build system detects the change.

  3. The build regenerates the Button.xml.d.ts file:

    interface ButtonFields {
        text: string;
        enabled: boolean;
        width: number;
        cornerRadius: number;  // ← New field
    }
  4. TypeScript recognizes the new field in your integrated development environment (IDE).

Advanced topics

The following sections describe additional features for working with XML component types.

Private fields

Fields with access="private" are excluded from generated TypeScript interfaces:

<interface>
    <field id="publicLabel" type="string" />
    <field id="internalState" type="string" access="private" />
</interface>

Generated interface

The generated interface excludes the private field:

interface ComponentFields {
    publicLabel: string;  // Public field included
    // internalState is not included because it is private to BrightScript
}

Private fields are accessible only within the component's BrightScript code; they are not accessible from TypeScript or JSX.

JSX element naming

The build converts component names to camelCase for JSX usage:

Component nameJSX element
Button<button />
MyCustomWidget<myCustomWidget />

Matching component names to file names

Whether a component name must match its file name depends on the type of import.

Source directory imports

For source directory imports, the XML file name must match the component name declared in the <component> tag (the build fails with a validation error if names do not match):

<!-- Button.xml -->
<component name="Button" extends="Group">

<!-- MyButton.xml: file name does not match component name -->
<component name="Button" extends="Group">

Static directory imports

For static directory imports (cross-static imports), the component name does not need to match the file name. As a result, you can import pre-existing components from the pkg/ directory, which may follow different naming conventions.

Side-effect imports for JSX only

If you need only JSX type augmentation without using the component name, use a side-effect import:

import "./components/Button.xml";  // Side-effect import
typeof Button;  // Not imported as a variable

// But JSX still works:
<button text="Click me" />  // Full type safety

@ts-field annotations

The @ts-field annotation lets you override how an XML interface field is exposed to TypeScript JSX code without changing the BrightScript field definition. You can use it when:

  • A field is a BrightScript callback (array-typed) but should appear as an event handler function in JSX
  • A field should have a different prop name in JSX than its BrightScript field id
  • A field's BrightScript type does not accurately represent the TypeScript type you want
  • A field should be excluded from the generated JSX interface entirely

Syntax

You must place an HTML comment immediately above the <field> that it annotates:

<!-- @ts-field <fieldId> { <options> } -->
<field id="<fieldId>" type="..." />

The options object uses JSON5 syntax (trailing commas, unquoted keys, single-quoted strings allowed).

Supported options

The options object supports the following options:

OptionTypeDescription
propNamestringOverride the JSX prop name (for example, "onAnimationStart")
typestringOverride the TypeScript type (for example, "(args: [number, number]) => void")
excludebooleanIf true, exclude this field from the JSX props interface entirely

Example: event callback with renamed prop

BrightScript "output" fields can represent events. In BrightScript, they are arrays that you observe through observeField, but in TypeScript JSX you want them to appear as callback props:

<component name="TweenFloat" extends="Group">
    <interface>
        <field id="targetValue" type="float" value="0.0" onChange="onTargetValueChanged" />

        <!-- @ts-field animationStart { propName: "onAnimationStart", type: "(t: [number, number]) => void" } -->
        <field id="animationStart" type="array" value="[0,0]" />

        <!-- @ts-field animationStop { propName: "onAnimationStop", type: "(t: [number, number]) => void" } -->
        <field id="animationStop" type="array" value="[0,0]" />
    </interface>
</component>

The build generates the following interfaces:

interface TweenFloatFields {
    targetValue: number;
    animationStart: unknown[];
    animationStop: unknown[];
}

interface ITweenFloatProps extends IGroupProps, Partial<TweenFloatFields> {
    onAnimationStart?: (t: [number, number]) => void;
    onAnimationStop?: (t: [number, number]) => void;
}

In JSX, you use the generated props as follows:

<tweenFloat
    targetValue={1.0}
    onAnimationStart={(t) => console.log("started at", t)}
    onAnimationStop={(t) => console.log("stopped at", t)}
/>

Example: type override with imported types

You can reference types from other packages using TypeScript import() expressions:

<!-- @ts-field boundingWidth { propName: "onBoundingWidth", type: 'import("@roku-sdk/components").EventHandler<number>' } -->
<field id="boundingWidth" type="float" alwaysNotify="false" />

Example: narrowing an array type

The following annotation narrows the array type to number[]:

<!-- @ts-field proposedStops { type: "number[]" } -->
<field id="proposedStops" type="array" value="[]" onChange="onProposedStopsChanged" />

This overrides the default unknown[] mapping for array to the more specific number[].

Example: excluding a field

The following annotation excludes a field from the JSX props:

<!-- @ts-field internalCounter { exclude: true } -->
<field id="internalCounter" type="integer" />

The field remains in ComponentFields (for BrightScript interoperability) but is excluded from the JSX props interface.

Duplicate annotations

If multiple @ts-field annotations reference the same field id, the build uses the last one and emits a warning.

Validation

The build system validates @ts-field annotations at compile time:

  • The build rejects unknown option keys with an error (this catches typos, such as propsName instead of propName).
  • The fieldId in the annotation must match the id of the immediately following <field> element.
  • JSON5 parse errors include file and line context.

Synchronizing declarations before the type check

During a build (rk build, rk dev), the SDK automatically synchronizes .xml.d.ts files before running the TypeScript compiler. This ensures that all XML components reachable from your project have up-to-date type declarations, even for XML files that you have not explicitly imported yet.

This synchronization does the following:

  • Discovers all XML files transitively referenced by your tsconfig.json include paths
  • Generates or updates .xml.d.ts files only when content has changed
  • Runs automatically before tsc as part of the build pipeline; therefore, you do not need a manual step.

To get the same declarations without a full build (for example, so that pnpm run type-check works on a fresh checkout), run:

rk gen-types

Example apps add this command to their type-check script:

{
    "scripts": {
        "type-check": "rk gen-types && tsc --noEmit"
    }
}

See rk gen-types for more information.

Once generated, your IDE's TypeScript language server picks up the .xml.d.ts files for autocomplete and error checking without needing a full rebuild.

Troubleshooting

The following sections describe problems with XML component type generation and their solutions.

componentMapping entry missing

Problem: The build fails for a component with a custom extends.

Example error:

XML component 'MyButton' extends 'CustomBase', but 'CustomBase' is not defined in dtsOptions.componentMapping.

Solution: Add a mapping in top-level dtsOptions in roku-config.ts:

const config: RokuConfig = {
    app: {
        entryPoint: "src/index.ts"
    },
    dtsOptions: {
        componentMapping: {
            CustomBase: {
                interface: "ICustomBaseProps",
                module: "./src/components/CustomBase.xml"
            }
        }
    }
};

.d.ts files not updating

Problem: Changes to the XML interface are not reflected in TypeScript types.

Solutions:

  1. Restart the TypeScript server in VS Code: press Cmd+Shift+P and select TypeScript: Restart TS Server.
  2. Run a clean build with rm -rf dist-build && rk build.
  3. Check that the .xml.d.ts file was regenerated (check the timestamp).
  4. Verify that the XML is valid and has a <component> tag.

TypeScript cannot find types for imported XML

Problem: The import shows the error "Cannot find module './Component.xml'".

Solutions:

  1. Verify that the file path is correct (it is case-sensitive).
  2. Check that the XML has a valid <component name="..."> declaration.
  3. Verify that the .d.ts file exists next to the XML file.
  4. Run the build once to generate the initial .d.ts files.
  5. Check that your TypeScript version supports .d.ts resolution.

JSX element not recognized

Problem: Using <myComponent /> shows a TypeScript error.

Solutions:

  1. Verify that the file that uses JSX imports the XML: import "./components/MyComponent.xml".
  2. Check that the component name matches the file name.
  3. Verify that the JSX element uses camelCase, not PascalCase: <myComponent />, not <MyComponent />.
  4. Restart the TypeScript server.

BrightScript files are not included in output

Problem: The XML references BrightScript files that are not in dist/components/.

Solutions:

  1. Verify that the <script> tags in the XML use the correct uri attribute.
  2. Check that the BrightScript files exist at the specified paths (relative to the XML file).
  3. Verify that BrightScript file names do not conflict with other BrightScript files.
  4. Check the build output for warnings about missing script files.

.d.ts files in Git history

Problem: The .xml.d.ts files were accidentally committed.

Solutions:

  1. The .xml.d.ts files should already be in .gitignore.
  2. Remove the files from Git with git rm --cached **/*.xml.d.ts.
  3. Verify that .gitignore has the entry *.xml.d.ts.
  4. Commit the .gitignore update.

Type conflicts with multiple XML files

Problem: Two XML components have the same name but different locations.

Solution: Component names must be unique across your project. The build fails with a conflict error. Rename one of the components:

<!-- Before -->
<component name="Button" extends="Group">  <!-- ui/Button.xml -->
<component name="Button" extends="Group">  <!-- dialogs/Button.xml -->

<!-- After -->
<component name="Button" extends="Group">       <!-- ui/Button.xml -->
<component name="DialogButton" extends="Group"> <!-- dialogs/DialogButton.xml -->

Missing types for static directory imports

Problem: Importing from pkg/ or a static directory does not provide types.

Solution: The .d.ts file is still generated, but in the static directory location. Try the following:

  1. Verify that TypeScript can resolve the path (you may need a tsconfig.json paths configuration).
  2. Verify that the tsconfig.json exclude option does not exclude the static directory.
  3. Try an absolute path if the relative path does not resolve: import Component from 'pkg/components/Component.xml'.

Related documentation


Did this page help you?