Marko’s TypeScript support offers in-editor error checking, makes refactoring less scary, verifies that data matches expectations, and even helps with API design.
There are two (non-exclusive) ways to add TypeScript to a Marko project:
-
For sites and web apps, a
tsconfig.jsonfile at the project root is the only requirement:src/ package.json tsconfig.json -
For packages of Marko tags, the
"script-lang"attribute must be set to"ts"in themarko.json:/* marko.json */ { "script-lang": "ts" }
This will automatically expose type-checking and autocomplete for the published tags.
Tip
You can also use the script-lang method for sites and apps.
Marko will crawl up the directory looking for a marko.json with script-lang defined.
This helps when incrementally migrating to TypeScript allowing folders to opt-in or opt-out of strict type checking.
A .marko file will use any exported Input type for that file’s input object.
This can be export type Input or export interface Input.
/* PriceField.marko */
export interface Input {
currency: string;
amount: number;
}
<label>
Price in ${input.currency}:
<input type="number" value=input.amount min=0 step=0.01>
</label>Since it is exported, Input may be accessed from other .marko and .ts files:
import { Input as PriceInput } from "<PriceField>";
import { ExtraTypes } from "lib/utils.ts";
export type Input = PriceInput & ExtraTypes;import { Input as PriceInput } from "<PriceField>";
export interface Input extends PriceInput {
discounted: boolean;
expiresAt: Date;
};Generic Types and Type Parameters on Input are recognized throughout the entire .marko template (excluding static statements).
export interface Input<T> {
options: T[];
onSelect: (newVal: T) => unknown;
}
static function staticFn() {
// can NOT use `T` here
}
<const/instanceFn(val: T) {
// can use `T` here
}/>
// can use `as T` here
<select onInput(evt) { input.onSelect(options[evt.target.value] as T) }>
<for|value, i| of=input.options>
<option value=i>${value}</option>
</for>
</select>Marko exposes common type definitions through the Marko TypeScript namespace:
Marko.Template<Input, Return>- The type of a
.markofile typeof import("./template.marko")
- The type of a
Marko.TemplateInput<Input>- The object accepted by the render methods of a template. It includes the template's
Inputand$globalvalues.
- The object accepted by the render methods of a template. It includes the template's
Marko.Body<Params, Return>- Used to type tag content
Marko.Renderable- All values accepted by the
<${dynamic}/>tag string | Marko.Template | Marko.Body | { content: Marko.Body | Marko.Template | string }
- All values accepted by the
Marko.Global- The type of the
$globalobject - Extended with application specific properties
- The type of the
Marko.RenderedTemplate- The result of rendering a Marko template
ReturnType<Marko.Template["render"]>
Marko.MountedTemplate<Input, Return>- The result of mounting a Marko template
ReturnType<Marko.Template["mount"]>
Marko.NativeTags- An object containing all native tags and their types
- Each entry is a
Marko.NativeTag, sodivattributes areMarko.NativeTags["div"]["input"]
Marko.NativeTag<Input, Return>- The type of a single entry in
Marko.NativeTags Inputtypes the tag's attributes,Returnthe element from its tag variable
- The type of a single entry in
Marko.HTMLAttributes<T>andMarko.SVGAttributes<T>- The global attributes and events shared by all HTML tags and all SVG tags, respectively
Ttypes the element passed toon*handlers, defaulting toElement
Marko.Input<TagName>andMarko.Return<TagName>- Helpers to extract the input and return types from native tags (when a string is passed) or custom tags.
Marko.BodyParameters<Body>andMarko.BodyReturnType<Body>- Helper to extract the parameters and return types from a
Marko.Body
- Helper to extract the parameters and return types from a
Marko.AttrTag<T>- Used to represent types for attributes tags
- A single attribute tag, with a
[Symbol.iterator]to consume any repeated tags
Types for the Class API, such as Marko.Component, Marko.Out, and Marko.Emitter, are no longer included in Marko 6. They remain available through the marko@5 package when using multiple Marko versions.
A commonly used type from the Marko namespace is Marko.Body which can be used to type the content in input.content:
/* child.marko */
export interface Input {
content?: Marko.Body;
}Here, all of the following are acceptable:
/* index.marko */
<child/>
<child>Text in render body</child>
<child>
<div>Any combination of components</div>
</child>Passing other values (including components) causes a type error:
/* index.marko */
import OtherTag from "<other-tag>";
<child content=OtherTag/>Tag parameters are provided to the content by the child tag. For this reason, Marko.Body allows typing of its parameters:
/* for-by-two.marko */
export interface Input {
to: number;
content: Marko.Body<[number]>
}
<for|i| from=0 to=input.to step=2>
<${input.content}(i)/>
</for>/* index.marko */
<for-by-two|i| to=10>
<div>${i}</div>
</for-by-two>All attribute tags are typed as iterable with a [Symbol.iterator], regardless of intent. This means all attribute tag inputs must be wrapped in Marko.AttrTag.
/* my-select.marko */
export interface Input {
option: Marko.AttrTag<Marko.HTML.Option>
}
<select>
<for|option| of=input.option>
<option ...option/>
</for>
</select>The types for native tags are accessed via the global Marko.HTML namespace. Here's an example of a component that extends the button html tag:
/* color-button.marko */
export interface Input extends Marko.HTML.Button {
color: string;
}
<const/{ color, ...attrs }=input>
<button style=`color: ${color}` ...attrs/>Tip
Since Marko 6, native tags have supported including content as an attribute so there is no need to inject manually
<button style=`color: ${color}` ...attrs>
// no longer required!
<${input.content}/>
</button>SVG tag types live in the parallel Marko.SVG namespace.
export interface Input extends Marko.SVG.Path {
dashed: boolean;
}
<const/{ dashed, ...attrs }=input>
<path fill="none" stroke-dasharray=dashed && "6 3" ...attrs/>A custom element is declared as an HTML tag in the project's marko.json, which tag discovery reads:
/* marko.json */
{
"<range-slider>": { "html": true }
}Its types are added to the Marko.NativeTags interface:
/* range-slider.ts */
export class RangeSliderElement extends HTMLElement {
value = 0;
}
interface RangeSliderAttributes extends Marko.HTMLAttributes<RangeSliderElement> {
value?: number;
step?: number;
}
declare global {
namespace Marko {
interface NativeTags {
"range-slider": Marko.NativeTag<RangeSliderAttributes, RangeSliderElement>;
}
}
}Extending Marko.HTMLAttributes carries over the global HTML attributes and events, and its type parameter types the element passed to those event handlers.
/* index.marko */
<let/threshold=20/>
<range-slider/sliderEl value=threshold step=5 onChange(evt, target) { threshold = target.value }/>
<button onClick() { sliderEl().focus() }>Adjust</button>declare global {
namespace Marko {
interface HTMLAttributes {
"my-non-standard-attribute"?: string; // Adds this attribute as available on all HTML tags.
}
}
}SVG tags take their global attributes from Marko.SVGAttributes, augmented the same way.
The style= object is typed with Marko.CSS.Properties, which extends csstype's PropertiesHyphen, so keys are hyphen-case CSS property names.
declare global {
namespace Marko {
namespace CSS {
interface Properties {
"--foo"?: string; // adds a support for a custom `--foo` css property.
}
}
}
}Marko.Global includes an index signature, so any property may be placed on $global, but undeclared properties read back as unknown. Declaring them types $global in every template and render call. In a dedicated declaration file, the leading export {} makes declare global apply.
export {};
declare global {
namespace Marko {
interface Global {
locale?: string;
requestId?: string;
}
}
}Warning
A property declared without ? is required in every $global passed to render or mount, since Marko.TemplateInput types $global as the whole Marko.Global.
Any JavaScript expression in Marko can also be written as a TypeScript expression.
<my-tag foo=1 as any>
${(input.el as HTMLInputElement).value}
</my-tag><child <T>|value: T|>
...
</child>/* components/child.marko */
export interface Input<T> {
value: T;
}/* index.marko */
// number would be inferred in this case, but we can be explicit
<child<number> value=1 /><child process<T>() { /* ... */ } />The types of attribute values can usually be inferred. When needed, you can assert values to be more specific with TypeScript’s as keyword:
<some-component
number=1 as const
names=[] as string[]
/>For existing projects that want to incrementally add type safety, adding full TypeScript support is a big leap. This is why Marko also includes full support for incremental typing via JSDoc.
You can enable type checking in an existing .marko file by adding a // @ts-check comment at the top:
// @ts-checkIf you want to enable type checking for all Marko & JavaScript files in a JavaScript project, you can switch to using a jsconfig.json. You can skip checking some files by adding a // @ts-nocheck comment to files.
Once that has been enabled, you can start by typing the input with JSDoc. Here's an example component with typed input:
// @ts-check
/**
* @typedef {{
* firstName: string,
* lastName: string,
* }} Input
*/
<div>${firstName} ${lastName}</div>For type checking Marko files outside of your editor there is the @marko/type-check cli. See the CLI documentation for more information.
The --generateTrace flag can be used to determine the parts of a codebase which are using the most resources during type checking.
mtc --generateTrace TRACE_DIR