Types
Every feature in FeatureQL has a type, and the language does not coerce between type families automatically. If you add a BIGINT to a DOUBLE, you get an error — you must cast explicitly (Integer literals get special treatment when used alongside DECIMAL values).
This strictness catches bugs early and makes feature behavior predictable across backends.
Supported types
| Category | Types | Aliases |
|---|---|---|
| Integer | BIGINT | INT64 |
MEDIUMINT | INT32 | |
SMALLINT | INT16 | |
TINYINT | INT8 | |
| Decimal | DECIMAL | |
| Floating Point | FLOAT | FLOAT32 |
DOUBLE | FLOAT64 | |
| String | VARCHAR | |
| Boolean | BOOLEAN | |
| Temporal | TIMESTAMP | |
DATE | ||
INTERVAL | ||
| Document | JSON | |
| Complex | ARRAY | LIST |
ROW | STRUCT |
FeatureQL does not support the MAP type. Use an ARRAY of ROWs with an INDEX instead — see Array of Rows for details.
Type inference
Most of the time you don't need to 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 - No implicit coercion across type families — mixing
BIGINTwithDOUBLEin arithmetic is an error. Write1::DOUBLE + 2e0instead.
BIGINT literal promotion
Integer literals adapt when used alongside DECIMAL values. When a BIGINT literal appears directly next to a DECIMAL in arithmetic, comparisons, or BETWEEN, FeatureQL treats it as DECIMAL(N, 0) where N is the number of digits:
WITH
PRICE := 10.25
SELECT
ADDITION := PRICE + 1,
MULTI_STEP := 10 + PRICE + 5,
COMPARISON := PRICE > 10,
ADDITION_TYPE := TYPEOF(ADDITION),
MULTI_STEP_TYPE := TYPEOF(MULTI_STEP)
;| ADDITION VARCHAR | MULTI_STEP VARCHAR | COMPARISON BOOLEAN | ADDITION_TYPE VARCHAR | MULTI_STEP_TYPE VARCHAR |
|---|---|---|---|---|
| 11.25 | 25.25 | true | DECIMAL(5,2) | DECIMAL(6,2) |
10 + PRICE + 5 works because each addition involves a literal next to a DECIMAL — but 10 + 5 + PRICE would fail, because 10 + 5 evaluates first as BIGINT arithmetic, producing a computed value that is no longer a literal. Use @literal(10 + 5) + PRICE to collapse the computation back into a literal.
This promotion applies only to integer literals with DECIMAL. It does not extend to DOUBLE (write 1::DOUBLE explicitly), and does not apply inside ARRAY or ROW constructors where elements must match types exactly.
DECIMAL values with different precisions are compatible in most contexts — DECIMAL(3,2) + DECIMAL(2,1) produces DECIMAL(4,2). FeatureQL automatically widens to the necessary precision.
When you need explicit types
You only declare types in two places:
- Inputs —
INPUT(BIGINT)declares a feature that accepts bound values of a specific type - External source mappings —
EXTERNAL_COLUMNS(...)andEXTERNAL_SQL(...)need column types because FeatureQL can't infer them from an external system
Entity annotations
Entities are the core business objects in your data model. You declare them with ENTITY() and can define several in a single query:
SELECT
CUSTOMERS := ENTITY(),
STORES := ENTITY(),
ORDERS := ENTITY() By convention, entity names use plural forms — CUSTOMERS, not CUSTOMER.
Link a feature to an entity using a type annotation like BIGINT#CUSTOMERS:
SELECT
CUSTOMERS := ENTITY(),
CUSTOMER_ID := INPUT(BIGINT#CUSTOMERS) The #CUSTOMERS annotation tells FeatureQL that CUSTOMER_ID is a key for the CUSTOMERS entity. This enables relationship tracking — when you later use RELATED() or EXTEND(), FeatureQL knows which entities connect to which.
Entity IDs support BIGINT, VARCHAR, and TIMESTAMP — covering numeric identifiers, UUIDs, and categorical keys.
Type casting
Two equivalent syntaxes for converting between types — standard SQL and PostgreSQL shorthand:
SELECT
CAST('123' AS BIGINT), -- Standard SQL casting
'123'::BIGINT, -- Shorthand syntax
'Amount: ' || 123::VARCHAR || 'EUR' -- :: casting takes higher precedence| ?_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.
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 |
BITSTRING | VARCHAR |
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.
For casts that may fail at runtime, TRY_CAST() returns NULL instead of raising an error — useful when working with external data that may contain invalid values.
Casting with entity annotations
Entity annotations are semantic boundaries — they tell FeatureQL "this value identifies a specific business object." Changing or removing an entity annotation could silently break relationship tracking, so FeatureQL requires UNSAFE_CAST() for any operation that modifies the entity annotation. Regular CAST() and :: only work when the entity stays the same.
WITH
ENTITY1 := ENTITY(),
ENTITY2 := ENTITY(),
ENTITY1_ID := INPUT(BIGINT#ENTITY1),
SELECT
ENTITY1_ID,
CAST_REMOVE_ENTITY := UNSAFE_CAST(ENTITY1_ID AS BIGINT),
CAST_CHANGE_ENTITY := UNSAFE_CAST(ENTITY1_ID AS BIGINT#ENTITY2),
TYPEOF(ENTITY1_ID) as TYPE0,
TYPEOF(CAST_REMOVE_ENTITY) as TYPE1,
TYPEOF(CAST_CHANGE_ENTITY) as TYPE2,
-- ENTITY1_ID + 1, -- IMPOSSIBLE
UNSAFE_CAST(UNSAFE_CAST(ENTITY1_ID AS BIGINT) + 1 AS BIGINT#ENTITY1) as NOW_POSSIBLE, -- If you really want to do it
TYPEOF(NOW_POSSIBLE) as TYPE3,
FOR
ENTITY1_ID := BIND_VALUES(SEQUENCE(1,3))
;| ENTITY1_ID BIGINT | CAST_REMOVE_ENTITY BIGINT | CAST_CHANGE_ENTITY BIGINT | TYPE0 VARCHAR | TYPE1 VARCHAR | TYPE2 VARCHAR | NOW_POSSIBLE BIGINT | TYPE3 VARCHAR |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 2 | BIGINT#ENTITY1 |
| 2 | 2 | 2 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 3 | BIGINT#ENTITY1 |
| 3 | 3 | 3 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 4 | BIGINT#ENTITY1 |
Three patterns for entity annotation changes, all requiring UNSAFE_CAST():
- Remove:
UNSAFE_CAST(ENTITY1_ID AS BIGINT)— strips the annotation - Change:
UNSAFE_CAST(ENTITY1_ID AS BIGINT#ENTITY2)— reassigns to a different entity - Preserve through arithmetic: cast away, compute, cast back —
UNSAFE_CAST(UNSAFE_CAST(ID AS BIGINT) + 1 AS BIGINT#ENTITY1)
The TYPEOF() calls in the result confirm each transformation.
In normal feature definitions, entity annotations should be set once — in INPUT() declarations or EXTERNAL_COLUMNS() mappings — and flow unchanged through the computation graph. If you find yourself reaching for UNSAFE_CAST() frequently, it likely signals a modeling issue in your entity boundaries rather than a casting problem.
Type inspection
TYPEOF() returns the FeatureQL type, while SQLTYPEOF() returns the type in the target backend. These can differ — FeatureQL's BIGINT might be INTEGER in DuckDB or INT64 in BigQuery:
SELECT
1 AS FEATURE1,
TYPEOF(FEATURE1) AS FEATUREQL_TYPE_OF_1, -- The FeatureQL type
SQLTYPEOF(FEATURE1) AS SQL_TYPE_OF_1 -- The type in backend database| FEATURE1 BIGINT | FEATUREQL_TYPE_OF_1 VARCHAR | SQL_TYPE_OF_1 VARCHAR |
|---|---|---|
| 1 | BIGINT | INTEGER |
This distinction helps when debugging: if a query works in one backend but not another, comparing TYPEOF() and SQLTYPEOF() tells you whether the issue is in the FeatureQL layer or the backend translation.