Type Inference¶
Scythe infers types from your SQL schema and query structure. The key insight: not all columns are nullable, and not all nullable columns stay nullable after transformation.
Neutral Type System¶
Scythe uses a language-neutral type vocabulary internally. Each backend maps these to concrete types.
| Neutral Type | PostgreSQL Source |
|---|---|
bool |
BOOLEAN |
int16 |
SMALLINT |
int32 |
INTEGER, SERIAL |
int64 |
BIGINT, BIGSERIAL |
float32 |
REAL |
float64 |
DOUBLE PRECISION |
string |
TEXT, VARCHAR, CHAR |
bytes |
BYTEA |
decimal |
NUMERIC, DECIMAL |
uuid |
UUID |
date |
DATE |
time |
TIME |
time_tz |
TIME WITH TIME ZONE |
datetime |
TIMESTAMP |
datetime_tz |
TIMESTAMPTZ |
interval |
INTERVAL |
json |
JSON, JSONB |
inet |
INET, CIDR |
Nullability from JOINs¶
Columns from the right side of a LEFT JOIN are always nullable, even if the column is defined as NOT NULL:
-- @name GetUserOrders
-- @returns :many
SELECT u.id, u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
| Column | Type | Nullable | Reason |
|---|---|---|---|
u.id |
int32 |
no | Left side of LEFT JOIN |
u.name |
string |
no | Left side of LEFT JOIN |
o.total |
decimal |
yes | Right side of LEFT JOIN |
Similarly, columns from the left side of a RIGHT JOIN become nullable.
Nullability from COALESCE¶
COALESCE strips nullability when the last argument is a non-null literal or expression:
-- @name GetUserDisplayName
-- @returns :one
SELECT COALESCE(nickname, name, 'Anonymous') AS display_name
FROM users WHERE id = $1;
display_name is non-nullable because the final fallback 'Anonymous' is a non-null literal.
-- @name GetUserNickname
-- @returns :one
SELECT COALESCE(nickname, name) AS display_name
FROM users WHERE id = $1;
If both nickname and name are nullable columns, display_name remains nullable.
Nullability from Aggregates¶
Aggregate functions have specific nullability rules:
| Function | Nullable? | Reason |
|---|---|---|
COUNT(*) |
no | Always returns a number |
COUNT(col) |
no | Always returns a number |
SUM(col) |
yes | Returns NULL for empty sets |
AVG(col) |
yes | Returns NULL for empty sets |
MIN(col) |
yes | Returns NULL for empty sets |
MAX(col) |
yes | Returns NULL for empty sets |
-- @name GetUserStats
-- @returns :one
SELECT
COUNT(*) AS total_orders,
SUM(total) AS revenue,
MAX(created_at) AS last_order
FROM orders WHERE user_id = $1;
| Column | Nullable | Reason |
|---|---|---|
total_orders |
no | COUNT is never null |
revenue |
yes | SUM returns NULL for empty result |
last_order |
yes | MAX returns NULL for empty result |
Nullability from CASE¶
CASE expressions are nullable if any branch can produce NULL:
-- @name GetUserTier
-- @returns :one
SELECT
CASE
WHEN total_spent > 1000 THEN 'gold'
WHEN total_spent > 100 THEN 'silver'
ELSE 'bronze'
END AS tier
FROM users WHERE id = $1;
tier is non-nullable because all branches (including ELSE) produce non-null values.
tier is nullable because the implicit ELSE returns NULL.
Nullability from Expressions¶
Binary operations propagate nullability:
If either a or b is nullable, sum is nullable.
Manual Overrides¶
When the analyzer cannot determine nullability correctly, use annotations:
-- @name GetUser
-- @returns :one
-- @nullable bio
-- @nonnull computed_status
SELECT id, bio, some_complex_expression() AS computed_status
FROM users WHERE id = $1;
See Annotations for details on @nullable and @nonnull.