We use cookies to ensure you get the best experience on our website. Read our privacy policy

Skip to main content
Typesafe Data Attributes illustration showing code editor with type hints
Home Blog Astrojs

Typesafe Data Attributes In Astro

Building a typesafe system with intellisense for data attributes in Astro.

Published on:
Clock Time to complete: 20 minutes

Data attributes let you store extra information in HTML elements. Theyโ€™re perfect for passing data between HTML and JavaScript without breaking standards. In Astro, theyโ€™re very useful to pass data from props into the custom component constructor.

The standard format uses data- prefixes and kebab-case in the html element. For example, data-theme-type="dark" becomes camelCase dataset.themeType in JavaScript. Unforunately thereโ€™s no typesafety for the data attributes, until now!

Use Cases For Global Data Attributes

Global data attributes are useful when there is a script that can be used on any or most elements.

  • Check Circle

    Copy To Clipboard

  • Check Circle

    Animation Presets

  • Check Circle

    Tooltips

Use Cases For Component-Specific Data Attributes

Component-specific data attributes are useful when there is a script that can be used on a specific component.

  • Check Circle

    Passing data to a component script

  • Check Circle

    State Management

  • Check Circle

    Unique Animation Presets

Letโ€™s define the goals for our typesafety system:

  • Intellicense and type validation on the html element
  • HTMLElement dataset Intellicense and type validation in Typescript
  • Intellicense for custom components with unique data attributes in the both the html definition and the component constructor script.
  • Easily extend the attributes for custom components and html elements.

Data attribute values are always compiled to strings, so we need to parse them to the correct type when interacting with them in the script. Objects must be JSON.stringify() when setting in both the html element and TypeScript, and JSON.parse() when getting in TypeScript.

We can however set the data attribute to a number, boolean, string, or string union, and it will be compiled correctly to a string. Keep in mind when getting the data it will always be a string.

Autocomplete for global data attribute names will not appear in the html element, but it will show type validation.

There is no intellicense or type checking for custom data attributes directly in the <custom-component> html element, but there is for the global data attributes.

  • Check Circle

    Editor autocomplete

  • Check Circle

    Runtime type safety

  • Check Circle

    Component-specific attributes

  • Check Circle

    Improved developer experience

Start by creating a TypeScript declaration file src/custom-attributes.d.ts. Letโ€™s start with the global data attributes that are accessible on all html elements. Letโ€™s start with varying types including ones we donโ€™t want, like an object, to test the typesafety works.

src/custom-attributes.d.ts
type GlobalDataAttributes = CustomDataset<{
'data-animate-speed': number;
'data-animate-direction': 'left' | 'right' | 'top' | 'bottom';
'data-close-side-menu': boolean;
'data-clipboard-data': string;
'data-obj': {
some: string;
other: number;
};
}>;

We will need two different versions of the data attributes, one for the html element in kebab-case and one for Typescript in camelCase. The type for html elements allows for string, string unions, number, and boolean. All other types are converted to a string, and when accessing the data from Typescript it will always be a string.

Starting with the html element type transformers, letโ€™s turn the camelCase to kebab-case.

src/custom-attributes.d.ts
type KebabData<T extends Record<string, unknown>> = {
[K in keyof T as `data-${CamelToKebab<string & K>}`]: T[K];
};
type CamelToKebab<S extends string> = S extends `${infer T}-${infer U}` ? `${CamelToKebab<T>}-${Lowercase<U>}` : S extends `${infer T}${infer U}` ? (T extends Uppercase<T> ? (U extends `${Uppercase<string>}${string}` ? `${Lowercase<T>}${CamelToKebab<U>}` : `-${Lowercase<T>}${CamelToKebab<U>}`) : `${T}${CamelToKebab<U>}`) : S;

Now we can use the KebabData type to set global data attributes on html elements. But first lets make the types safer, so that only the allowed types are passed through, anything else will be a string.

src/custom-attributes.d.ts
type SimpleTypes = string | number | boolean | null | undefined;
type SafeTypes<T extends Record<string, unknown>> = {
[K in keyof T]: T[K] extends SimpleTypes ? T[K] : string;
};

Now letโ€™s put them together.

src/custom-attributes.d.ts
type HTMLDataAttributes<T extends Record<string, unknown>> = Partial<KebabData<SafeTypes<T>>>;

The result from this transformer is kebab-case with data- prefix and the allowed types for the html element.

To get the intellicense working we need to extend Astroโ€™s type definition for the standardhtml elements and custom component elements.

src/custom-attributes.d.ts
declare namespace astroHTML.JSX {
//Needed for all html tags
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface HTMLAttributes extends HTMLDataAttributes<GlobalDataAttributes> {}
//Needed for custom components (global data attributes directly in <custom-component> tags)
interface IntrinsicElements extends astroHTML.JSX.DefinedIntrinsicElements {
[name: string]: {
[name: string]: any;
} & HTMLDataAttributes<GlobalDataAttributes>;
}
}

Now we have intellicense for the global data attributes in the html elements, custom html elements, and the custom component props.

On to the custom component data attributes. Itโ€™s just one more transformer since the global data attributes are already camelCase.

src/custom-attributes.d.ts
type ComponentDataAttributes<T extends object> = Partial<
SafeTypes<
{
[K in keyof T]: T[K] extends string ? T[K] : string;
} & {
//Set value below to either:
//never: strict typing on custom components.
// string: loose (can set and look for any dataset that is declared)
[K: string]: string;
}
>
>;

Now let extend the DOMStringMap to include the component data attributes. After this our dataset will have intellicense in TypeScript.

src/custom-attributes.d.ts
interface DOMStringMap extends ComponentDataAttributes<GlobalDataAttributes> {}

Create a new file src/types.d.ts to export the types for the custom component.

src/types.d.ts
//Needed for custom component props to extend the global data attributes
export type HTMLGlobalDataAttributes = HTMLDataAttributes<GlobalDataAttributes>;
// Needed for custom components (global data attributes in imported <CustomComponent> tags)
export type HTMLAttributesWithData<T> = import('astro/types').HTMLAttributes<T> & HTMLGlobalDataAttributes;
//Export here to use in the components since Astro will not import `HTMLDataAttributes` from `src/custom-attributes.d.ts`
export type HTMLData<T> = HTMLDataAttributes<T>;

Letโ€™s cover the custom component data attributes. For this we will create a folder for our custom component with three files:

src/
โ””โ”€โ”€ components
โ””โ”€โ”€ my-component
โ”œโ”€โ”€ MyComponent.astro
โ”œโ”€โ”€ index.ts
โ””โ”€โ”€ data.ts

src/components/my-component/data.ts Define the component data attributes and an exported type for the html data attributes.

src/components/my-component/data.ts
export type MyComponentDataAttributes = {
hello?: number;
world?: 'works' | 'with' | 'unions';
};

src/components/my-component/index.ts Define the constructor class for the custom component and import the data attributes. Declare the dataset in the class to get dataset intellicense in the class.

src/components/my-component/index.ts
import type { MyComponentDataAttributes } from './data';
class MyComponent extends HTMLElement {
declare dataset: DOMStringMap & ComponentDataAttributes<MyComponentDataAttributes>;
constructor() {
super();
if (Number(this.dataset.hello) === 10) {
console.log(this.dataset.hello);
}
if (this.dataset.world === 'works') {
console.log(this.dataset.world);
}
}
}
customElements.define('my-component', MyComponent);

Lastly create the file src/components/my-component/MyComponent.astro and use the HTMLAttributesWithData type for the props.

src/components/my-component/MyComponent.astro
---
import type { HTMLAttributesWithData, HTMLData } from '@/types';
import type { HTMLMyComponentDataAttributes } from './data';
export interface Props
extends HTMLAttributesWithData<'div'>,
HTMLData<HTMLMyComponentDataAttributes> {
text: string;
}
const { 'data-animate-direction': animateDirection, ...rest } = Astro.props;
console.log(animateDirection);
---
<div data-animate-direction={animateDirection} data-animate-speed={100} {...rest}>
{text}
</div>

I prefer to define a prop that sets the value of the attribute. This way you can set the attribute in the html element via the prop instead of the dataset, and make it required if needed.

src/components/my-component/MyComponent.astro
---
import type { HTMLAttributesWithData, HTMLData } from '@/types';
import type { HTMLMyComponentDataAttributes } from './data';
export interface Props extends HTMLAttributesWithData<'div'>, HTMLData<HTMLMyComponentDataAttributes> {
animateDirection: HTMLMyComponentDataAttributes['animateDirection'];
}
const { animateDirection, ...rest } = Astro.props;
---
<div data-animate-direction={animateDirection} {...rest}>
{text}
</div>

Note: Because data-animate-direction is also potentialy defined in ...rest we the prop value could be overwritten. To prevent this you can destructure the prop

Remember these important details:

  1. Numbers and booleans convert to strings in datasets
  2. Complex objects need JSON.stringify() this includes arrays
  3. Date objects should be converted to ISOstring before setting in the html element
  4. Always validate values from datasets:
// For booleans
const isTrue = element.dataset.active === 'true';
// For numbers
const count = Number(element.dataset.count);
// convert ISOstring to Date, can return invalid date if the string is not a valid ISOstring
const date = new Date(element.dataset.date);
// check if the date is valid
const validDate = isFinite(+date);
// For objects
type Data = {
hello: string;
world: number;
};
try {
//Consider using zod to validate the data attribute
const data: Data = JSON.parse(element.dataset.data || '{}') as Data | {};
} catch (error) {
console.error('Error parsing data attribute:', error);
}

Now that you have a typesafe system for data attributes, but thereโ€™s still one issue. When queryselecting an element, the intellicense for the data attributes will not work. Thereโ€™s a solution for this, you can read about it in the next article.

Custom Component Types illustration showing code editor with type hints

Custom Component Types in Astro

Learn how to create custom component types in Astro.

Limited Time Launch Sale

Our AstroJS starterkit is an all-in-one solution for your Astro projects. Get your project started with a professional design system, components, and documentation.

GET 60% OFF!
src/custom-attributes.d.ts
type GlobalDataAttributes = {
//Define your custom data attributes here
};
type ComponentDataAttributes<T extends object> = Partial<
SafeTypes<
{
[K in keyof T]: T[K] extends string ? T[K] : string;
} & {
//Set value below to either:
//never: strict typing on custom components.
// string: loose (can set and look for any dataset that is declared)
[K: string]: string;
}
>
>;
//Needed for all html tags
declare namespace astroHTML.JSX {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface HTMLAttributes extends HTMLDataAttributes<GlobalDataAttributes> {}
}
//Needed for custom components .astro files
declare module 'astro/types' {
type HTMLTag = keyof astroHTML.JSX.DefinedIntrinsicElements;
// Extend the HTMLAttributes type to include your custom data attributes
type HTMLAttributes<Tag extends HTMLTag> = Omit<astroHTML.JSX.DefinedIntrinsicElements[Tag], keyof Omit<import('astro').AstroBuiltinAttributes, 'class:list'>> & HTMLDataAttributes<GlobalDataAttributes>;
}
// Needed for any for interacting with elements
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface DOMStringMap extends ComponentDataAttributes<GlobalDataAttributes> {}
type SimpleTypes = string | number | boolean | null | undefined;
type HTMLDataAttributes<T extends Record<string, any>> = Partial<KebabData<SafeTypes<T>>>;
type KebabData<T extends Record<string, unknown>> = {
[K in keyof T as `data-${CamelToKebab<string & K>}`]: T[K];
};
type SafeTypes<T extends Record<string, any>> = {
[K in keyof T]: T[K] extends SimpleTypes ? T[K] : string;
};
type CamelToKebab<S extends string> = S extends `${infer T}-${infer U}` ? `${CamelToKebab<T>}-${Lowercase<U>}` : S extends `${infer T}${infer U}` ? (T extends Uppercase<T> ? (U extends `${Uppercase<string>}${string}` ? `${Lowercase<T>}${CamelToKebab<U>}` : `-${Lowercase<T>}${CamelToKebab<U>}`) : `${T}${CamelToKebab<U>}`) : S;
src/types.d.ts
//Needed for custom component props to extend the global data attributes
export type HTMLGlobalDataAttributes = HTMLDataAttributes<GlobalDataAttributes>;
// Needed for custom components (global data attributes in imported <CustomComponent> tags)
export type HTMLAttributesWithData<T> = import('astro/types').HTMLAttributes<T> & HTMLGlobalDataAttributes;
//Export here to use in the components since Astro will not import `HTMLDataAttributes` from `src/custom-attributes.d.ts`
export type HTMLData<T> = HTMLDataAttributes<T>;

Related Articles

Read more AstroJS articles
North Star Themes Logo

Subscribe to our newsletter

Get the latest AstroJS tips and tricks right to your inbox

Email: [email protected]

© 2025 North Star Themes

Web Kit Provided By North Star Themes