Type system

A comprehensive guide to the Indicator type system: static typing, how types are inferred from literals, explicit casts, the per-bar value that replaces a…

A comprehensive guide to the Indicator type system: static typing, how types are inferred from literals, explicit casts, the per-bar value that replaces a series, and the messages the compiler prints when a type does not fit. kScript (legacy) used a hybrid system, static analysis over a dynamic runtime with one number type and an implicit series type. An Indicator is AssemblyScript: every value has a fixed width, nothing converts implicitly, and there is no any.

Introduction

The compiler type-checks the whole file before it runs. There are no implicit conversions between numeric widths, no coercion between strings and numbers, and no union types. That strictness is the point: a mismatch is a compile error with a line and column, never a wrong number on bar 1,400.

At its core the system has two categories of values: primitives (f64, i32, bool, string) and references (arrays, maps, class instances). What it does not have is a series type. kScript's domain-specific strength was timeseries, a value with a whole history behind it; an Indicator's central idea is the opposite: one value per bar, read through a function, with history kept explicitly. This page covers declarations and inference, the primitives, the per-bar model, collections, conversion rules, and a complete example.

Variable declarations and type inference

let and const declare variables, as in TypeScript. The type comes from the annotation if there is one, otherwise from the initializer, and the rule to remember is the literal rule: a whole-number literal is an i32, a literal with a decimal point is an f64.

let price = 45000.5;     // f64
let count = 0;           // i32, because the literal has no point
let flag = true;         // bool
let name = "BTCUSDT";    // string

let value: f64 = 0;      // annotated: f64 (the literal widens)
let cursor: i32 = 0;     // annotated: i32

Once inferred, the type is fixed. let count = 0; count = 2.5; is refused with Conversion from type 'f64' to 'i32' requires an explicit cast, which is also the message you get when a param (always f64) meets a class constructor (always i32): write new Sma(i32(p_period())).

const fixes the binding, not the contents: a const window = new StaticArray<f64>(50) is filled and overwritten freely; only window = ... is refused. Module-level TA objects are let, because init() rebuilds them at the chosen period.

The three lifetimes (a local, a module-level variable, a value with history) are in core-variables.md; this page is about what the values are.

Primitive types

f64 is the 64-bit float and the type of everything that crosses the host boundary. Arithmetic on two f64 values is an f64; dividing by zero gives Infinity or NaN, never an error, so guard denominators. NaN is the missing value: isNaN(x) tests it, x == x is false when x is NaN, and every comparison with NaN is false.

i32 is the 32-bit integer: periods, counters, cursors, loop bounds, the state() return, Cross.update()'s answer. Integer division truncates (7 / 2 is 3). i64 exists for epoch-second math (time-and-sessions.md).

bool comes from comparisons and logical operators. A bare number in an if compiles (nonzero is true), but write the comparison out: x > 0.0 says what you mean, and !isNaN(x) is the only reliable test for a missing value.

string is immutable text with length, charCodeAt, indexOf, startsWith, + between strings, and == / < by code unit. Strings stay inside the module and reach the chart only through a declared string slot; building one allocates, so per-bar text goes through the line builder (sb_clear, sb_text, sb_f64). There is no number-to-string coercion: "close " + close is refused with Type 'f64' is not assignable to type 'String'; write close.toString().

The per-bar value: what replaces TimeSeries

A kScript timeseries was a complete history aligned to bars; the engine tracked it for you and [n] looked backward. An Indicator input is a function: in_close() returns this bar's close, and only in state(). The host calls state() once per bar, oldest first, so a line you write once still produces a value at every point in time, but the past is not addressable.

What holds history:

NeedForm
close[1]a module-level prevClose assigned at the end of state()
sma(close, 20), rsi(close, 14)new Sma(20), new Rsi(14): the class keeps the window, update(x) folds one value
highest(high, 20), close[5]a StaticArray<f64> ring buffer of 20 with a cursor
cum(delta)a module-level accumulator

Immutability is built in (an input has no setter), and module scope is where history lives: anything that must survive a call is declared there, because a local is gone when state() returns.

OHLCV data structure

A candle is not one value with six columns. Declare one input per field you read, and read each through its own accessor:

input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("close", ohlcv.close);
input("volume", ohlcv.volume);
input("bar_t", time.bar_open_sec);   // the timestamp, epoch seconds

The first input is the primary: it defines the grid (market and interval) every other input aligns to. There is no priceIndex: a class takes whichever number you hand it, so sma.update(in_close()) and sma.update((in_high() + in_low()) / 2.0) are the same call over different sources.

Arrays and collections

Arrays are typed and homogeneous. StaticArray<f64> is a fixed-size block allocated once; Array<f64> grows with push; f64[] and string[] literals work; Map<K, V> is a key-value store. The element type is fixed at declaration and enforced: pushing a string onto an Array<f64> is refused with Type 'String' is not assignable to type 'f64'. Arrays are references: passing one to a function passes the same storage, so a function that writes into it writes into yours. The allocation rule applies to all of them: build them in init() or at module scope, never per bar (collections.md).

const periods: i32[] = [10, 20, 50];
const window = new StaticArray<f64>(200);
const weights = new Map<string, f64>();

Data sources

kScript offered a direct function per feed and the universal source("type", ...). An Indicator has one form, input(name, source.field, options), where source is a namespace from ./sdk/declare and field one of its members; every input is an f64, and the source decides what the number means. The catalog, the missing policy for sparse feeds, and the named refusals for feeds a host cannot fetch are in data-sources.md.

Type conversion and casting

Nothing converts on its own. The rules:

  • f64 to i32: i32(x) truncates toward zero. Periods, cursors, and loop bounds derived from a param need it.
  • i32 to f64: f64(n). Writing a counter to an output needs it, and so does mixing an i32 into float arithmetic.
  • bool to a number: a ternary, flag ? 1.0 : 0.0.
  • A number to a string: .toString(), and only for module-internal text.
  • Never implicit: f64 * i32 is refused; cast one side.
const period = i32(p_period());          // f64 to i32
out_count(f64(hits));                    // i32 to f64
out_is_long(fast > slow ? 1.0 : 0.0);    // bool to f64
const label = "n=" + hits.toString();    // i32 to string, inside the module

Type mismatch errors name the rule. The ones you will meet:

MessageCauseFix
AS200: Conversion from type 'f64' to 'i32' requires an explicit cast.a param or float where an integer goesi32(...)
TS2322: Type 'bool' is not assignable to type 'i32'.a comparator returning a comparisonreturn a > b ? 1 : a < b ? -1 : 0
TS2322: Type 'String' is not assignable to type 'f64'.a string pushed onto a numeric arraykeep the array homogeneous
TS2329: Index signature is missing in type 'f64'.close[1] on a numberkeep the previous value in a variable
TS1110: Type expected.a function without a return typeannotate: function f(x: f64): f64
AS100: Not implemented: Closuresan arrow function reading a local of the enclosing functionread module-level state instead (lambdas-and-reducers.md)
AS100: Not implemented: Iteratorsfor (const x of xs)an index loop

Best practices

Annotate every module-level let and every function signature (the annotation is documentation, and it stops the literal rule from making a float an integer); feed inputs straight into the classes that keep the windows; keep params, inputs, and outputs at the top as top-level statements, state under them, the four functions last.

Complete example: multi-source momentum

The kScript type-system page closed with a momentum indicator over three sources. The port reads the same three: the close for an RSI, the funding rate averaged over 24 bars, and liquidations (sparse, so missing: "zero" makes a quiet bar a 0). The extreme condition is a data-only gate and a mark drawn only where it holds.

import { funding, input, line, liquidations, lower, none, ohlcv, output, param, shape } from "./sdk/declare";
import { in_close, in_funding, in_liqs } from "./gen/inputs";
import { emitRow, out_extreme, out_extreme_rsi, out_is_extreme, out_rsi } from "./gen/outputs";
import { p_extreme_rsi, p_funding_threshold, p_rsi_period } from "./gen/params";
import { Rsi, Sma } from "./sdk/ta";

// Configuration: the RSI length, the RSI level, and the funding level.
param("rsi_period", 14, { min: 2, max: 100, description: "RSI period" });
param("extreme_rsi", 30, { min: 1, max: 99, description: "RSI at or below this is oversold" });
param("funding_threshold", 0.01, { min: 0, max: 100, description: "Funding below minus this means shorts are paying, in the feed's own units" });
// Data sources: price, funding, liquidations.
input("close", ohlcv.close);
input("funding", funding.rate_close, { description: "Funding rate at the bar's close" });
input("liqs", liquidations.liquidations, { missing: "zero", description: "Liquidation volume, 0 on quiet bars" });
// Visualization: the RSI and its level in one pane, a mark only on extreme bars.
output("rsi", line, lower, { color: "#2962FF", width: 2, description: "Relative Strength Index" });
output("extreme_rsi", line, lower, { color: "#FF6B35", width: 1, description: "Extreme RSI level" });
output("extreme", shape, lower, { color: "#00BA88", shape_where: "is_extreme", description: "Oversold, shorts paying, liquidations printing" });
output("is_extreme", none, lower);

let rsi = new Rsi(14);
let avgFunding = new Sma(24); // 24 bars of funding, one day on a 1h chart
let liqAvg = new Sma(6); // six bars of liquidation volume
let extremeLevel: f64 = 30.0;
let fundingThreshold: f64 = 0.01;
let rsiValue: f64 = NaN;
let extreme: bool = false;

export function init(): void {
  rsi = new Rsi(i32(p_rsi_period()));
  extremeLevel = p_extreme_rsi();
  fundingThreshold = p_funding_threshold();
}

export function state(): i32 {
  rsiValue = rsi.update(in_close()); // f64 in, f64 out
  const fundingAvg = avgFunding.update(in_funding());
  const liqRecent = liqAvg.update(in_liqs());
  // Three bools, combined; NaN on any side makes its comparison false, so a warming source never fires.
  const oversold: bool = rsiValue <= extremeLevel;
  const shortsPaying: bool = fundingAvg < -fundingThreshold;
  const liquidating: bool = liqRecent > 0.0;
  extreme = oversold && shortsPaying && liquidating;
  return isNaN(rsiValue) ? 0 : 1;
}

export function finalize(): void {
  out_rsi(rsiValue);
  out_extreme_rsi(extremeLevel);
  out_extreme(rsiValue); // the mark sits on the RSI line
  out_is_extreme(extreme ? 1.0 : 0.0); // bool to f64
  emitRow();
}

export function reset(): void {
  rsi.reset();
  avgFunding.reset();
  liqAvg.reset();
  rsiValue = NaN;
  extreme = false;
}

Watch the types flow: three f64 inputs feed three classes that return f64, those numbers feed comparisons that return bool, the bools combine into one bool, and the ternary turns it into the f64 gate output that the shape output reads through shape_where. Nothing converts on its own, and every step is checked before the first bar runs.