TypeScript

Marko’s TypeScript support offers in-editor error checking, makes refactoring less scary, verifies that data matches expectations, and even helps with API design.

Enabling TypeScript in your Marko project

There are two (non-exclusive) ways to add TypeScript to a Marko project:

  • For sites and web apps, a tsconfig.json file 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 the marko.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.

Typing input

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.

Example

PriceField.marko
<label>
  Price in ${input.currency}:${" "}
  <input type="number" value=input.amount min=0 step=0.01>
</label>
export interface Input {
  currency: string;
  amount: number;
}

<label>
  Price in ${input.currency}:${" "}
  <input type="number" value=input.amount min=0 step=0.01>
</label>
label
  -- Price in ${input.currency}:${" "}
  input type="number" value=input.amount min=0 step=0.01
export interface Input {
  currency: string;
  amount: number;
}

label
  -- Price in ${input.currency}:${" "}
  input type="number" value=input.amount min=0 step=0.01

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";
import { Input as PriceInput } from "<PriceField>";
import { ExtraTypes } from "lib/utils.ts";
export type Input = PriceInput & ExtraTypes
import { Input as PriceInput } from "<PriceField>";
import { ExtraTypes } from "lib/utils.ts";
import { Input as PriceInput } from "<PriceField>";
import { ExtraTypes } from "lib/utils.ts";
export type Input = PriceInput & ExtraTypes
import { Input as PriceInput } from "<PriceField>";
import { Input as PriceInput } from "<PriceField>";
export interface Input extends PriceInput {
  discounted: boolean;
  expiresAt: Date;
}
import { Input as PriceInput } from "<PriceField>";
import { Input as PriceInput } from "<PriceField>";
export interface Input extends PriceInput {
  discounted: boolean;
  expiresAt: Date;
}

Generic Input

Generic Types and Type Parameters on Input are recognized throughout the entire .marko template (excluding static statements).

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>
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>
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}
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}

Built-in Marko Types

Marko exposes common type definitions through the Marko TypeScript namespace:

  • Marko.Template<Input, Return>
    • The type of a .marko file
    • typeof import("./template.marko")
  • Marko.TemplateInput<Input>
    • The object accepted by the render methods of a template. It includes the template's Input and $global values.
  • Marko.Body<Params, Return>
  • Marko.Renderable
    • All values accepted by the <${dynamic}/> tag
    • string | Marko.Template | Marko.Body | { content: Marko.Body}
  • Marko.Global
  • Marko.RenderResult
  • Marko.NativeTags
    • Marko.NativeTags: An object containing all native tags and their types
  • Marko.Input<TagName> and Marko.Return<TagName>
    • Helpers to extract the input and return types from native tags (when a string is passed) or custom tags.
  • Marko.BodyParameters<Body> and Marko.BodyReturnType<Body>
    • Helper to extract the parameters and return types from a Marko.Body
  • Marko.AttrTag<T>
    • Used to represent types for attributes tags
    • A single attribute tag, with a [Symbol.iterator] to consume any repeated tags

Deprecated

  • Marko.Component<Input, State>
  • Marko.Out
    • The render context with methods like write, beginAsync, etc.
    • ReturnType<template.render>
  • Marko.Emitter
    • EventEmitter from @types/node

Typing content

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>
child
child -- Text in render body
child
  div -- Any combination of components

Passing other values (including components) causes a type error:

index.marko
import OtherTag from "<other-tag>";
<child content=OtherTag/>
import OtherTag from "<other-tag>";
child content=OtherTag

Typing Tag Parameters

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
<for|i| from=0 to=input.to by=2>
  <${input.content}(i)/>
</for>
export interface Input {
  to: number;
  content: Marko.Body<[number]>
}

<for|i| from=0 to=input.to by=2>
  <${input.content}(i)/>
</for>
for|i| from=0 to=input.to by=2
  ${input.content}(i)
export interface Input {
  to: number;
  content: Marko.Body<[number]>
}

for|i| from=0 to=input.to by=2
  ${input.content}(i)
index.marko
<for-by-two|i| to=10>
  <div>${i}</div>
</for-by-two>
for-by-two|i| to=10
  div -- ${i}

Typing Attribute Tags

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
<select>
  <for|option| of=input.option>
    <option ...option/>
  </for>
</select>
export interface Input {
  option: Marko.AttrTag<Marko.Input<"option">>
}

<select>
  <for|option| of=input.option>
    <option ...option/>
  </for>
</select>
select
  for|option| of=input.option
    option ...option
export interface Input {
  option: Marko.AttrTag<Marko.Input<"option">>
}

select
  for|option| of=input.option
    option ...option

Extending native tag types within a Marko tag

The types for native tags are accessed via the global Marko.Input type. Here's an example of a component that extends the button html tag:

color-button.marko
$ const { color, ...attrs } = input

<button style=`color: ${color}` ...attrs>
  <content/>
</button>
export interface Input extends Marko.Input<"button"> {
  color: string;
  content?: Marko.Body;
}
$ const { color, ...attrs } = input

<button style=`color: ${color}` ...attrs>
  <content/>
</button>
$ const { color, ...attrs } = input

button style=`color: ${color}` ...attrs
  content
export interface Input extends Marko.Input<"button"> {
  color: string;
  content?: Marko.Body;
}
$ const { color, ...attrs } = input

button style=`color: ${color}` ...attrs
  content

Registering a new native tag (e.g. for custom elements)

interface MyCustomElementAttributes {
  // ...
}

declare global {
  namespace Marko {
    interface NativeTags {
      // By adding this entry, you can now use `my-custom-element` as a native html tag.
      "my-custom-element": MyCustomElementAttributes;
    }
  }
}

Registering new "global" HTML Attributes

declare global {
  namespace Marko {
    interface HTMLAttributes {
      "my-non-standard-attribute"?: string; // Adds this attribute as available on all HTML tags.
    }
  }
}

Registering CSS Properties (eg for custom properties)

declare global {
  namespace Marko {
    namespace CSS {
      interface Properties {
        "--foo"?: string; // adds a support for a custom `--foo` css property.
      }
    }
  }
}

TypeScript Syntax in .marko

Any JavaScript expression in Marko can also be written as a TypeScript expression.

<my-tag foo=1>
  ${(input.el as HTMLInputElement).value}
</my-tag>
<my-tag foo=1 as any>
  ${(input.el as HTMLInputElement).value}
</my-tag>
my-tag foo=1 -- ${(input.el as HTMLInputElement).value}
my-tag foo=1 as any -- ${(input.el as HTMLInputElement).value}

Tag Type Parameters

<child <T>|value: T|>
  ...
</child>
child <T>|value: T| -- ...

Tag Type Arguments

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/>
// number would be inferred in this case, but we can be explicit
child<number> value=1

Method Shorthand Type Parameters

<child process<T>() { /* ... */ }/>
child process<T>() { /* ... */ }

Attribute Type Assertions

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 names=[]/>
<some-component number=1 as const names=([] as string[])/>
some-component number=1 names=[]
some-component number=1 as const names=([] as string[])

JSDoc Support

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.

Setup

You can enable type checking in an existing .marko file by adding a // @ts-check comment at the top:

// @ts-check

If 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>
// @ts-check
/**
 * @typedef {{
 *   firstName: string,
 *   lastName: string,
 * }} Input
 */

div -- ${firstName} ${lastName}

CI Type Checking

For type checking Marko files outside of your editor there is the @marko/type-check cli. See the CLI documentation for more information.

Profiling Performance

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

Contributors

Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.