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

CategoryTypesAliases
IntegerBIGINTINT64
MEDIUMINTINT32
SMALLINTINT16
TINYINTINT8
DecimalDECIMAL
Floating PointFLOATFLOAT32
DOUBLEFLOAT64
StringVARCHAR
BooleanBOOLEAN
TemporalTIMESTAMP
DATE
INTERVAL
DocumentJSON
ComplexARRAYLIST
ROWSTRUCT

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' is VARCHAR, 1 is BIGINT, 1.25 is DECIMAL(3,2), 1.25e0 is DOUBLE
  • Operators and functions1 + 2 produces BIGINT because both operands are BIGINT
  • No implicit coercion across type families — mixing BIGINT with DOUBLE in arithmetic is an error. Write 1::DOUBLE + 2e0 instead.

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:

FeatureQL
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)
;
Result
ADDITION VARCHARMULTI_STEP VARCHARCOMPARISON BOOLEANADDITION_TYPE VARCHARMULTI_STEP_TYPE VARCHAR
11.2525.25trueDECIMAL(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:

  • InputsINPUT(BIGINT) declares a feature that accepts bound values of a specific type
  • External source mappingsEXTERNAL_COLUMNS(...) and EXTERNAL_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()
sql

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)
sql

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:

FeatureQL
SELECT
    CAST('123' AS BIGINT),                      -- Standard SQL casting
    '123'::BIGINT,                              -- Shorthand syntax
    'Amount: ' || 123::VARCHAR || 'EUR'         -- :: casting takes higher precedence
Result
?_0 BIGINT?_1 BIGINT?_2 VARCHAR
123123Amount: 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:

FromTo
BIGINTDECIMAL, DOUBLE, VARCHAR
DECIMALBIGINT, DOUBLE, VARCHAR
DOUBLEBIGINT, DECIMAL, VARCHAR
VARCHARBIGINT, DECIMAL, DOUBLE, DATE, TIMESTAMP, BITSTRING
DATETIMESTAMP, VARCHAR
TIMESTAMPDATE, VARCHAR
BITSTRINGVARCHAR

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.

FeatureQL
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))
;
Result
ENTITY1_ID BIGINTCAST_REMOVE_ENTITY BIGINTCAST_CHANGE_ENTITY BIGINTTYPE0 VARCHARTYPE1 VARCHARTYPE2 VARCHARNOW_POSSIBLE BIGINTTYPE3 VARCHAR
111BIGINT#ENTITY1BIGINTBIGINT#ENTITY22BIGINT#ENTITY1
222BIGINT#ENTITY1BIGINTBIGINT#ENTITY23BIGINT#ENTITY1
333BIGINT#ENTITY1BIGINTBIGINT#ENTITY24BIGINT#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:

FeatureQL
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
Result
FEATURE1 BIGINTFEATUREQL_TYPE_OF_1 VARCHARSQL_TYPE_OF_1 VARCHAR
1BIGINTINTEGER

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.

Last update at: 2026/03/03 16:47:38
Last updated: 2026-03-03 16:48:19