Types
Every feature in FeatureQL has a type. Numerics use two words that do not overlap:
- Category —
exactorapproximate. Exact values add without rounding error. Approximate values are binary floating point. - Family —
integeranddecimalunder exact;floatingunder approximate (FLOAT,DOUBLE). - Rank — width inside a family:
TINYINT<SMALLINT<INT<BIGINT, andFLOAT<DOUBLE.
Producing operators (+, -, *, >) mix within a category, so integer and decimal combine. // keeps the dividend's type instead. Keep-forms (COALESCE, IF, CASE, ARRAY, GREATEST) mix within a family only (ranks widen). Crossing categories is allowed only when the exact side is a literal (1 + 1e0, 1.5 * 2e0). Naming or computing the exact value removes that exception — X := 1; X + 1e0 is an error and you must cast.
FeatureQL translates each query to SQL for DuckDB, Trino, and serving (DataFusion), and each backend coerces types differently. What silently widens in one might truncate in another. Explicit casts keep the result the same everywhere.
Promotion rules, parametric defaults, and the conversion table are later on this page.
Type families
The types you meet first. For numerics, family is the second row of the glossary above — not another word for exact vs approximate.
| Family | Category | Write | Also accepted |
|---|---|---|---|
| Integer | exact | BIGINT | INT, SMALLINT, TINYINT |
| Decimal | exact | DECIMAL | — |
| Floating | approximate | DOUBLE | FLOAT |
| Text / boolean | — | VARCHAR, BOOLEAN | STRING, BOOL |
| Temporal | — | TIMESTAMP, DATE | TIMESTAMPTZ, INTERVAL |
| Structured | — | ARRAY, ROW | LIST, STRUCT |
Catalog, geo/binary/JSON, and INDEX: All types . How you write a literal picks the family (1 vs 1.0 vs 1e0): Literals .
FeatureQL does not support MAP. For key/value lookups, build an INDEX with INDEX_UNIQUE() / INDEX_MULTI() — Lookup with indexes .
Inference
Most of the time you do not declare types. FeatureQL infers them from context:
- Literals —
'Hello'isVARCHAR,1isBIGINT,1.25isDECIMAL(3,2),1.25e0isDOUBLE - Operators and functions —
1 + 2producesBIGINTbecause both operands areBIGINT - Exact × approximate — a producing operator (
+,-,*,>) accepts an exact literal next to an approximate value and unifies both operands to the approximate category. A computed or named exact value does not: writeX::DOUBLE + 2e0.//does not take this exception: the result stays the dividend's type. Keep-forms (COALESCE,IF,CASE,ARRAY,GREATEST) never adapt across families.
When you need an explicit type
You declare a type in two places:
INPUT(BIGINT)— a placeholder you fill later inFOR. Binding is on Binding inputs .EXTERNAL_COLUMNS(...)— column types for a warehouse (or other) source, because FeatureQL cannot infer them. The shape of a mapping is at the end of Friendly syntax and in full on Mapping to data sources .
Everything else is inferred from the expressions you write.
Casting
Two equivalent syntaxes — standard SQL and PostgreSQL shorthand:
RETURN
'123'::BIGINT, -- Standard SQL casting
'123'::BIGINT, -- Shorthand syntax
'Amount: ' || 123::VARCHAR || 'EUR'
;| ?_0 BIGINT | ?_1 BIGINT | ?_2 VARCHAR |
|---|---|---|
| 123 | 123 | Amount: 123EUR |
The :: shorthand has higher precedence than most operators, which is why 123::VARCHAR || 'EUR' works without parentheses — the cast happens before the concatenation.
For casts that may fail at runtime, TRY CAST() returns NULL instead of raising an error.
Type inspection
TYPEOF() returns the FeatureQL type. SQLTYPEOF() returns the type in the target backend. These can differ — FeatureQL BIGINT is BIGINT on DuckDB, integer on Trino, and Int64 on serving:
RETURN
n := 1,
fql_type := TYPEOF(n),
backend_type := SQLTYPEOF(n)
;| N BIGINT | FQL_TYPE VARCHAR | BACKEND_TYPE VARCHAR |
|---|---|---|
| 1 | BIGINT | BIGINT |
If a query works in one backend but not another, compare the two. SQLTYPEOF spelling is engine-owned; FeatureQL results are not. Same answer across backends . More inspection tools: Tools in the query .
Semantic annotations
A type can carry business meaning on top of the datatype:
BIGINT#CUSTOMERS
DECIMAL(18,2)@CURRENCY.USD
#CUSTOMERS marks a key as identifying a customer. @CURRENCY.USD marks what a number measures. The compiler checks and propagates these labels; they are erased before SQL runs.
That is how FeatureQL rejects a customer key compared to a product key — both are BIGINT, but they do not identify the same thing:
DEFINE
orders := ENTITY(),
items := ENTITY(),
order_id := INPUT(BIGINT#ORDERS),
item_id := INPUT(BIGINT#ITEMS)
RETURN cross_entity_eq := order_id = item_id
FOR (order_id, item_id) := BIND_VALUES(ARRAY(ROW(1, 1), ROW(2, 3)))
;INPUT() and FOR here only supply the two keys; you will use them for real bindings later. Full model: Entities and keys and Units and measures .
Parametric defaults
Bare parametric types always expand to the same FeatureQL defaults on every backend:
| Written as | Means |
|---|---|
DECIMAL / NULL(DECIMAL) / CAST(… AS DECIMAL) | DECIMAL(18,6) |
TIMESTAMP / NULL(TIMESTAMP) / timestamp literals | TIMESTAMP(3) |
TIMESTAMPTZ / NULL(TIMESTAMPTZ) | TIMESTAMPTZ(3) |
Digit literals keep inferred precision (1.123 is DECIMAL(4,3)). Prefer an explicit (p,s) when the column is money or another fixed scale (e.g. DECIMAL(10,2)), especially in EXTERNAL_COLUMNS / JDBC DDL — bare SQL DECIMAL on Trino is DECIMAL(38,0), which is not FeatureQL’s default.
RETURN
t_null := TYPEOF(NULL::DECIMAL),
t_cast_lit := TYPEOF(10.05::DECIMAL),
t_cast_int := TYPEOF(10::DECIMAL),
t_cast_ws := TYPEOF(10.05::DECIMAL(18,6)),
t_cast_money := TYPEOF(10.05::DECIMAL(10,2)),
t_lit := TYPEOF(1.123),
t_typed_lit := TYPEOF(DECIMAL '1.123')
;| T_NULL VARCHAR | T_CAST_LIT VARCHAR | T_CAST_INT VARCHAR | T_CAST_WS VARCHAR | T_CAST_MONEY VARCHAR | T_LIT VARCHAR | T_TYPED_LIT VARCHAR |
|---|---|---|---|---|---|---|
| DECIMAL(18,6) | DECIMAL(18,6) | DECIMAL(18,6) | DECIMAL(18,6) | DECIMAL(10,2) | DECIMAL(4,3) | DECIMAL(4,3) |
Timestamps follow the same pattern — a bare TIMESTAMP is TIMESTAMP(3):
RETURN
t_null := TYPEOF(NULL::TIMESTAMP),
t_cast := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP),
t_lit := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'),
t_lit_ms := TYPEOF(TIMESTAMP '2024-01-01 12:00:00.123'),
t_p0 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(0)),
t_p3 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(3)),
t_p6 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(6)),
t_tstz := TYPEOF(NULL::TIMESTAMPTZ(3))
;| T_NULL VARCHAR | T_CAST VARCHAR | T_LIT VARCHAR | T_LIT_MS VARCHAR | T_P0 VARCHAR | T_P3 VARCHAR | T_P6 VARCHAR | T_TSTZ VARCHAR |
|---|---|---|---|---|---|---|---|
| TIMESTAMP(3) | TIMESTAMP(3) | TIMESTAMP(3) | TIMESTAMP(3) | TIMESTAMP(0) | TIMESTAMP(3) | TIMESTAMP(6) | TIMESTAMPTZ(3) |
Integer to DECIMAL promotion
On producing operators and comparisons, integers promote to DECIMAL when used alongside a DECIMAL value. This applies to both literal integers and computed integer features. Keep-forms do not promote; they only widen ranks or scales inside one family.
INPUT(BIGINT) below is a typed placeholder. DEFINE holds local definitions that are not returned — the same role as on Query structure .
DEFINE
PRICE := 10.25, -- DECIMAL(4,2)
QUANTITY := INPUT(BIGINT), -- a computed integer, not a literal
RETURN
PRICE + 5, -- works: literal 5 promotes to DECIMAL
PRICE + QUANTITY, -- works: computed BIGINT also promotes to DECIMAL
PRICE > QUANTITY, -- works: comparison promotes too
When an integer is promoted, FeatureQL treats it as DECIMAL(N, 0) where N depends on the integer type: N=3 for TINYINT, N=5 for SMALLINT, N=10 for INT, N=20 for BIGINT.
DEFINE
price := 10.25
RETURN
addition := price + 1,
multi_step := 10 + price + 5,
comparison := price > 10,
addition_tinyint := price + TINYINT '1',
addition_type := TYPEOF(addition),
multi_step_type := TYPEOF(multi_step),
addition_tinyint_type := TYPEOF(addition_tinyint)
;| ADDITION DECIMAL(5,2) | MULTI_STEP DECIMAL(6,2) | COMPARISON BOOLEAN | ADDITION_TINYINT DECIMAL(5,2) | ADDITION_TYPE VARCHAR | MULTI_STEP_TYPE VARCHAR | ADDITION_TINYINT_TYPE VARCHAR |
|---|---|---|---|---|---|---|
| 11.25 | 25.25 | true | 11.25 | DECIMAL(5,2) | DECIMAL(6,2) | DECIMAL(5,2) |
This promotion does not apply inside ARRAY or ROW constructors. Those keep-forms require the same family — integer, decimal, or floating — not only the same category. Within a family, ranks and decimal precisions unify: ARRAY[1.25, 1.1] produces ARRAY(DECIMAL(3,2)), and ARRAY[ROW(2::BIGINT), ROW(INT '2')] produces ARRAY(ROW(field_1 BIGINT)).
RETURN
decimals := ARRAY(1.20, 1.25, 1.10),
decimal_rows := ARRAY(ROW(1.2), ROW(1.25), ROW(1.))
;| DECIMALS ARRAY<DECIMAL(3,2)> | DECIMAL_ROWS ARRAY<ROW<field_1 DECIMAL(3,2)>> |
|---|---|
| [1.20, 1.25, 1.10] | [{field_1: 1.20}, {field_1: 1.25}, {field_1: 1.00}] |
The rule to remember: the two exact families (integer, decimal) mix on producing operators and comparisons. They do not mix inside keep-forms (ARRAY, ROW, COALESCE, IF, CASE). When the compiler asks for a cast inside a constructor, add an explicit ::DECIMAL or write the literal as a decimal (e.g., 5.0 instead of 5).
Supported conversions
CAST() and :: support conversions between these type pairs:
| From | To |
|---|---|
BIGINT | DECIMAL, DOUBLE, VARCHAR |
DECIMAL | BIGINT, DOUBLE, VARCHAR |
DOUBLE | BIGINT, DECIMAL, VARCHAR |
VARCHAR | BIGINT, DECIMAL, DOUBLE, DATE, TIMESTAMP, BITSTRING |
DATE | TIMESTAMP, VARCHAR |
TIMESTAMP | DATE, VARCHAR |
INTERVAL | VARCHAR |
BITSTRING | VARCHAR |
Same-type casts (for example CAST(x AS BINARY) when x is already BINARY) are always allowed. You cannot cast to BINARY, EMPTY, or UNSPECIFIED from another type — produce BINARY with X'…' / ST_ASWKB(), and use typed NULL(…) instead of casting to EMPTY/UNSPECIFIED. Geospatial, JSON, and BOOLEAN are also outside this conversion table; use typed literals or constructors instead of CAST.
Complex types have structural constraints: arrays can only be cast to other arrays (or JSON), rows to other rows, and arrays of rows to other arrays of rows.
Patch an annotation with CAST() by writing _ to keep the datatype, such as CAST(raw_id AS _#CUSTOMERS) or CAST(raw_amount AS _@CURRENCY.USD). Drop a known identity with KEY_TO_SCALAR(). Change one known identity for another, or relabel a unit, with UNSAFE_CAST(). See Entities and keys and Units and measures .
Troubleshooting type errors
| Error message | Cause | Fix |
|---|---|---|
FUNCTION-SIGNATURE-UNKNOWN on + with a named exact value and a DOUBLE | Computed exact × approximate (cross-category) is refused | Cast the named side (value::DOUBLE + 2.5e0) or write both as approximate literals (1e0 + 2.5e0). An exact literal such as 1.5 + 1e0 is already legal. // keeps the dividend's type instead |
| Array or ROW constructor type mismatch | Mixing two families (for example integer and decimal) inside ARRAY[...] or ROW(...) | Write all elements in the same family: ARRAY[10.25, 5.0] or ARRAY[10.25, 5::DECIMAL] |
Entity mismatch in RELATED() or EXTEND() | The VIA key has a different entity annotation than the target feature's INPUT | Check that your foreign key and the target entity's primary key share the same #ENTITY annotation |
ENTITY-CAST-LABEL-ONLY when changing #E | Regular CAST cannot change one entity to another | Use UNSAFE_CAST(value AS _#OTHER). If you need this often, reconsider your entity model |
ENTITY-CAST-ERASE-REQUIRES-UNSAFE | CAST(... AS _#UNDEFINED) is refused | Drop the identity with KEY_TO_SCALAR(value). The cast form of the same erase is UNSAFE_CAST(value AS _#UNDEFINED). Omitting # keeps the entity |
ANNOTATIONS-NOT-ALLOWED on > / < / BETWEEN with an identity key | Integer and string keys accept = and != only | Order two keys of one family with IS_KEY_BEFORE(). Compare a key to a plain bound with KEY_TO_SCALAR(). DATE#, TIMESTAMP#, and geography keys already accept <, >, and BETWEEN |
ANNOTATIONS-NOT-ALLOWED on order_id = item_id | Equality across different entity families | Compare keys within the same family, or drop one side with KEY_TO_SCALAR() |
UNIT-LABEL-MISMATCH on +, -, or a comparison | The two operands measure different things | Rescale with CONVERT_UNIT(), or multiply by a typed rate so the units cancel |
UNIT-CAST-LABEL-ONLY when changing @U | Regular CAST cannot turn one measured unit into another | CONVERT_UNIT() to rescale the value, or UNSAFE_CAST to relabel it without converting |