Skip to content

Pine v6’s type system, in practice

10 min read

PineScript v6 is more strongly typed than its reputation suggests. Walking through the type rules, the inference, the gotchas, and what shipping a transpiler taught us about which rules actually matter at runtime.

Pine Script has a reputation for being loosely typed. The reputation is partly deserved and mostly wrong. It earned the label because int widens to float silently, na is accepted almost everywhere, and the language rarely demands an explicit annotation. But underneath that permissive surface is a type system with real teeth, and once you need to transpile it to a statically typed language, every tooth matters.

This post walks through what Pine v6's types actually are, where the inference layer hides the complexity, and three gotchas that matter once you compile Pine to C++.

Reputation vs. reality

A new Pine programmer writes:

x = 5
y = close + x

No annotations. No complaints from the compiler. The programmer concludes "Pine has no types." But Pine inferred exactly what those variables are: x is a const int (a literal, known at compile time), and y is a series float (a per-bar floating-point value). The inference is doing real work; it's just invisible.

The distinction between const int and series float isn't cosmetic. They have different runtime behaviors, different operator compatibility rules, and different representations in a transpiled output. The language is typed; it just doesn't force you to write the types down.

The type kinds

This post looks at four layers of Pine v6's type system. Most users encounter only the first.

Primitives

The primitive types are int, float, bool, string, and color. There's also na, which deserves its own section below. These are the leaf types: the ones that hold scalar values.

int and float participate in implicit widening: when you mix them in an expression, the int widens to float. This is the behavior that earns Pine the "loosely typed" label, but it's a deliberate and consistent rule, not a special case. C++ does the same thing for numeric types.

Composites

Pine v6 supports three composite types: array<T>, matrix<T>, and map<K, V>. All three are parameterized by their element types, which can be UDTs, except that a map's keys must be a value type such as int or string. You can have array<float>, array<int>, even array<MyUDT>.

Composite types are mutable objects. They're reference types in the sense that passing an array<float> to a function gives the function a handle to the same underlying storage. If the function modifies the array, the caller sees the change.

User-defined types (UDTs)

Pine v5 added the type keyword in December 2022 and the method keyword in February 2023. Together they let you define named record types with fields and optional methods:

type Order
    float price
    int   qty
    bool  filled = false
 
method fill(Order this, float fillPrice) =>
    this.price  := fillPrice
    this.filled := true

UDTs are Pine's closest analog to structs. They can have default values for fields (as shown with filled = false). Methods are called with dot notation and receive the UDT instance as their first parameter, named this here. Fields are mutable via := assignment.

Form qualifiers: series vs. simple

Every value in Pine (not just primitives, but composites and UDTs too) carries a form qualifier that describes its relationship to the bar timeline.

  • series: the value can differ bar by bar. It has history: x[1] gives you the value of x on the previous bar. This is the default for most expressions involving price data.
  • simple: the value is set on the first bar and stays fixed for the rest of the run, like syminfo.ticker.
  • input: the value comes from the script's settings through input.int() and its siblings: fixed for the run, known when the script starts.
  • const: the value is known at compile time: every literal, and anything declared like const int VERSION = 6. The codegen can write it straight into the C++ (the first gotcha below shows how).

The qualifiers form a hierarchy, const < input < simple < series, and an expression takes the strongest qualifier among its parts: simple int + series float is series float.

na semantics

na is not a universal null: Pine still needs to know which type is missing. That is why this line does not compile:

x = na

There is nothing to infer the type from. Declare it instead:

float x = na

Arithmetic on na propagates: na + 1 is na. This is intentional and consistent: if a value is unknown, any expression depending on it is also unknown. The escape hatch is nz(x, 0), which replaces na with a default value.

Comparisons behave differently: ==, != and every other comparison come out false when an operand holds na, and writing na itself as an operand, as in x == na, does not compile. Use the built-in predicate na(x) instead.

In PineForge's C++, na is a sentinel value of the variable's own type, and na(x) becomes is_na(x). Generated comparisons check both sides with is_na first, so a comparison with na comes out false.

Type inference in detail

Pine infers types from the right-hand side of assignments. The rules are straightforward once you know the form qualifier hierarchy:

a = 5           // const int
b = 5.0         // const float
c = close       // series float  (close is always series float)
d = close + 1   // series float  (int widens to float; series promotes)
e = bar_index   // series int    (bar_index is series int, always)

The interesting case is inference across branches:

x = condition ? close : 0

condition might be series bool. close is series float. 0 is const int. Pine widens 0 to series float and the result is series float. The compiler always picks the most general form and the most encompassing type.

Function arguments constrain inference too. If a built-in expects simple int and you pass bar_index (which is series int), you get a compile error. This is where the type system's strictness surfaces: you can't pass a bar-varying value to a parameter that requires a fixed-at-startup value.

How the C++ codegen represents these types

Translating Pine's type system to C++ requires mapping each Pine form and type to a C++ representation that has the right runtime behavior.

The details below match the headers of engine v1.0.0 and the C++ that codegen 1.0.0 generates.

Primitives map to C++ scalars: double for float, int or int64_t for int (some computed int values, bar_index and hour among them, are held in a double), bool for bool, std::string for string, and int64_t for color.

Series are ring buffers of history. The engine's class, from include/pineforge/series.hpp:

template <typename T>
class Series {
public:
    void push(T value);             // a new bar
    void update(T value);           // the same bar again (bar magnifier)
    T operator[](int offset) const; // offset bars back; na if out of range
    T current() const;
};

When Pine code writes close[2], the generated C++ reads _s_close[2].

na is a sentinel of the value's own type (include/pineforge/na.hpp): na<double>() is a quiet NaN, na<int>() is the smallest int, and na<bool>() is false. is_na(x) tests for it, and nz(x, y) becomes an inline is_na(x) ? y : x.

UDTs become handles: the generated struct holds an id, the fields live in records the strategy owns (so a checkpoint can roll them back), and each method becomes a function that takes the handle, for example _udt_Acc_add(Acc self, double v). A field write is an assignment to the record's field; there are no setters.

Arrays become std::vector<T>. Matrices use the engine's NumericMatrix (over Eigen's MatrixXd) for matrix<float> and GenericMatrix<T> for every other element type, matrix<int> included; the generated code still names them PineMatrix and PineGenericMatrix<T>, aliases engine v1.0.0 keeps.

Three gotchas

1. A value that never changes is not necessarily const

atrLen     = 14
fastLength = input.int(22, "Fast EMA Length")
atr  = ta.atr(atrLen)
fast = ta.ema(close, fastLength)

Neither length changes during a run, but only one of them is known at compile time. atrLen is const int: a literal is const, and a variable initialized with one inherits the qualifier. fastLength is input int: its value comes from the script's settings when the script starts.

The generated C++ draws the same line. In the open corpus, atrLen = 14 followed by ta.atr(atrLen) compiles to ta::ATR(14), with the 14 written into the code. fastLength = input.int(22, "Fast EMA Length") compiles to get_input_int("Fast EMA Length", 22), read when the backtest starts. That is what lets one generated strategy run with different inputs: the local MCP server's backtest_pine_grid transpiles the Pine once and runs the same C++ with every combination of input values.

2. UDT field assignment doesn't trigger methods, and that's intentional

type Box
    float value
 
method doubled(Box this) =>
    this.value * 2
 
b = Box.new(value = 10.0)
b.value := 20.0   // direct field write, no notification

In an object-oriented language, you might expect b.value := 20.0 to go through a setter that could call doubled() or trigger any other behavior. In Pine, it doesn't. Fields are public and directly mutable. Methods are just functions scoped to the type. If you're building a state machine in a UDT and you want validation on write, you must explicitly call a method that does the validation. Field writes bypass everything.

It's the main place where a Pine program can look like it has encapsulation but doesn't. The generated C++ reflects this: a field write is an assignment to the record's field, with no method in between.

3. request.security defaults to causal, but the lookahead default has changed across versions

htf_close = request.security(syminfo.tickerid, "D", close)

This call is type-correct and compiles without warnings. What it returns is the daily close as of the current bar's time. If the current bar is a 15-minute bar mid-day, htf_close carries the previous day's close until the new daily bar closes. That's the causal interpretation.

The historical gotcha: in Pine v2, security defaulted to lookahead=barmerge.lookahead_on, which allowed peeking at the future daily bar before it closed. This inflated backtest results for strategies that used HTF data. Pine v3 changed the default to lookahead_off (causal), which is the correct behavior, and every version since, v6 included, keeps it.

The type system doesn't warn you about this because both modes are type-correct. Without a lookahead argument, the PineForge codegen applies lookahead_off semantics, matching the Pine v6 default.

Why transpiling Pine made us more rigorous

Every ambiguity in Pine's type rules has to become a decision in the C++ codegen. When Pine's reference documentation says "this is implementation-defined" or when two behaviors are both technically consistent with the spec, we can't pick one and hope. We have to test against TradingView's actual output, figure out which branch TradingView took, and implement that branch.

This process has made us read the Pine v6 language reference more carefully than any user would bother to. It also produced many of the strategies in the open corpus: when we find a corner case in request.security or strategy.exit where our codegen disagrees with TradingView, we write a minimal reproducer, add it to the corpus, and fix the codegen until both outputs match.

The type system is where corner cases live. The form qualifiers (series vs. simple vs. const) look like a minor implementation detail, but they determine which operations are legal, which values can be passed to which functions, and which expressions can be evaluated at compile time rather than bar by bar. Getting them right is the difference between a transpiler and a good one.

Where to go next

  • Transpile from Claude or Cursor: the local MCP server's transpile_pine tool returns the C++ if you want to inspect these representations.
  • Browse the gallery: each of the 312 open-corpus strategies shows its Pine source and the C++ generated from it.
  • Read why we built this: the origin story of the engine and the decision to target C++ as the transpilation target.