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 ofxon 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, likesyminfo.ticker.input: the value comes from the script's settings throughinput.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 likeconst 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_pinetool 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.