> ## Documentation Index
> Fetch the complete documentation index at: https://villagesql.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Types in Rust

> Define new column types in the VillageSQL Rust SDK — binary layout, encode, decode, compare, hash, and arithmetic functions with the custom_type! macro.

<Warning>
  The Rust SDK is in alpha — expect breaking API changes between
  releases. Function-only extensions, aggregate functions, varargs
  functions, and custom types (encode, decode, compare, hash) are
  supported, as are the `sys_var`, `status_var`, `thread_worker` and
  `keyring` preview capabilities. The column storage ABI is C++-only
  today — use the [C++ SDK](/docs/mysql-9.7/dev/create) if you need it.
</Warning>

Custom types let you define new column types — like `RATIONAL`, `VECTOR`, or `INET` — that work with `ORDER BY`, indexes, and aggregate functions. The Rust SDK supports this through the `custom_type!` macro, and through `parameterized_type!` for types whose storage size depends on column parameters (see [Parameterized Types](#parameterized-types)).

This page assumes you've already worked through [Creating Extensions in Rust](/docs/mysql-9.7/dev/rust-sdk). The setup (Cargo.toml, manifest.json, cargo-vsql) is the same.

## When to use a custom type

Use a custom type when:

* You need a binary on-disk layout that a standard SQL type can't express (packed floats, fixed-width integers, binary identifiers)
* Your type has its own ordering semantics that differ from lexicographic string ordering
* You want the server to index and hash values correctly for `ORDER BY`, `COUNT(DISTINCT)`, and set operations

If you only need SQL-callable functions and your data fits comfortably in `STRING`, `INT`, or `REAL` columns, you don't need a custom type.

## The custom\_type! macro

Every custom type needs 4 callbacks (encode, decode, compare, hash) and a default value. Here's the full macro signature:

```rust theme={null}
villagesql::custom_type!(
    type_name: "type_name_in_sql",
    persisted_length: N,
    max_decode_buffer_length: M,
    encode: your_encode_fn,
    decode: your_decode_fn,
    compare: your_compare_fn,
    hash: your_hash_fn,
    default: "a_valid_string_literal",
)
```

| Field                      | Type           | Description                                                                                                                             |
| -------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `type_name`                | string literal | The SQL type name. Case-insensitive in SQL.                                                                                             |
| `persisted_length`         | `usize`        | Fixed byte length for on-disk storage.                                                                                                  |
| `max_decode_buffer_length` | `usize`        | Maximum byte length of the decoded string representation.                                                                               |
| `encode`                   | fn             | Converts a `&str` to binary bytes at INSERT time.                                                                                       |
| `decode`                   | fn             | Converts binary bytes back to a `String` for display.                                                                                   |
| `compare`                  | fn             | Returns `Ordering` for `ORDER BY`, `MIN`, `MAX`.                                                                                        |
| `hash`                     | fn             | Returns a `usize` hash for `COUNT(DISTINCT)` and set operations. Optional but recommended for indexed columns.                          |
| `default`                  | string literal | A valid string the server can encode at type initialization. Must encode to exactly `persisted_length` bytes. Optional but recommended. |

`type_name`, `persisted_length`, `max_decode_buffer_length`, `encode`, `decode`, and `compare` are required. `hash` and `default` are optional but recommended — `hash` is needed for correct `COUNT(DISTINCT)` and set operations, and `default` is needed for type initialization verification.

## Receiving and returning binary values

Functions that take or return a custom type work with raw bytes.

**Input** — `InValue::Custom(b)` carries the stored binary as `&[u8]`:

```rust theme={null}
fn rational_numer_impl(args: &[InValue]) -> VdfReturn {
    match args.first() {
        Some(InValue::Custom(b)) => {
            let numer = read_i64(b, 0);
            VdfReturn::int(numer)
        }
        Some(InValue::Null) | None => VdfReturn::null(),
        _ => VdfReturn::error("rational_numer: expected a RATIONAL argument"),
    }
}
```

**Output** — `VdfReturn::Binary(bytes)` sends binary bytes back to the server:

```rust theme={null}
fn rational_add_impl(args: &[InValue]) -> VdfReturn {
    match (args.get(0), args.get(1)) {
        (Some(InValue::Custom(a)), Some(InValue::Custom(b))) => {
            let result = add_rationals(a, b);
            VdfReturn::Binary(result)
        }
        _ => VdfReturn::null(),
    }
}
```

To reference a custom type in a `func!` declaration, use `villagesql::custom!("type_name")`:

```rust theme={null}
villagesql::func!(
    rational_add_impl,
    "rational_add",
    [villagesql::custom!("rational"), villagesql::custom!("rational")] -> villagesql::custom!("rational"),
    deterministic: true
)
```

## Example: rational number type

`examples/vsql_rational` in the SDK repo is a working extension implementing a `RATIONAL` type. It stores a rational number as a 16-byte pair of `i64` values (numerator, denominator) in little-endian byte order and provides arithmetic functions.

Here are the core encode, decode, compare, and hash implementations:

```rust theme={null}
use villagesql::{InValue, VdfReturn};

// Binary layout: [numerator: i64 LE][denominator: i64 LE] — 16 bytes total.
// Always stored in reduced form (GCD = 1) with a positive denominator.
const BYTES: usize = 16;

fn to_bytes(num: i64, den: i64) -> Vec<u8> {
    let mut v = Vec::with_capacity(BYTES);
    v.extend_from_slice(&num.to_le_bytes());
    v.extend_from_slice(&den.to_le_bytes());
    v
}

fn from_bytes(b: &[u8]) -> (i64, i64) {
    let num = i64::from_le_bytes(b[..8].try_into().unwrap());
    let den = i64::from_le_bytes(b[8..16].try_into().unwrap());
    (num, den)
}

// encode: "3/4" -> 16 bytes
pub fn rational_encode(s: &str) -> Result<Vec<u8>, String> {
    let (num_s, den_s) = s
        .split_once('/')
        .ok_or_else(|| format!("rational: expected 'n/d', got {:?}", s))?;
    let num: i64 = num_s.trim().parse()
        .map_err(|e| format!("rational numerator: {}", e))?;
    let den: i64 = den_s.trim().parse()
        .map_err(|e| format!("rational denominator: {}", e))?;
    let (n, d) = normalize(num as i128, den as i128)
        .ok_or_else(|| "rational: zero or overflowing denominator".to_string())?;
    Ok(to_bytes(n, d))
}

// decode: 16 bytes -> "3/4"
pub fn rational_decode(b: &[u8]) -> Result<String, String> {
    if b.len() < BYTES {
        return Err(format!("rational: expected {} bytes, got {}", BYTES, b.len()));
    }
    let (n, d) = from_bytes(b);
    Ok(format!("{}/{}", n, d))
}

// compare: for ORDER BY, MIN, MAX
pub fn rational_compare(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
    let (n1, d1) = from_bytes(a);
    let (n2, d2) = from_bytes(b);
    // cross-multiply (denominators are always positive)
    let lhs = (n1 as i128) * (d2 as i128);
    let rhs = (n2 as i128) * (d1 as i128);
    lhs.cmp(&rhs)
}

// hash: for COUNT(DISTINCT) and set operations
pub fn rational_hash(b: &[u8]) -> usize {
    // FNV-1a over the 16 bytes
    let mut h: usize = 0xcbf29ce484222325u64 as usize;
    for &byte in b {
        h ^= byte as usize;
        h = h.wrapping_mul(0x100000001b3u64 as usize);
    }
    h
}
```

The `custom_type!` registration and the arithmetic VDFs (`rational_add`, `rational_sub`, etc.) are in the full source at `examples/vsql_rational/src/lib.rs`.

With the extension installed:

```sql theme={null}
INSTALL EXTENSION vsql_rational;

CREATE TABLE fractions (
    id   INT PRIMARY KEY,
    val  RATIONAL
);

INSERT INTO fractions VALUES (1, '1/2'), (2, '3/4'), (3, '1/4');

-- ORDER BY uses rational_compare
SELECT val FROM fractions ORDER BY val;
-- → 1/4, 1/2, 3/4

-- Arithmetic with rational_add
SELECT rational_add('1/3', '1/6');
-- → 1/2

-- Extract numerator and denominator
SELECT rational_numer(val), rational_denom(val) FROM fractions;

-- Convert to floating-point approximation
SELECT rational_to_real('1/3');
-- → 0.3333333333333333
```

`rational_to_real(r RATIONAL) -> REAL` converts a `RATIONAL` value to a 64-bit floating-point approximation by dividing the numerator by the denominator. Useful when you need an approximate decimal for display or comparison but don't want to store the lossy representation in the column.

## Parameterized types

A parameterized type needs a value read at `CREATE TABLE` time — like the
`3` in `VECTOR(3)` — to know its storage size. `custom_type!` can't express
this: its `persisted_length` is a single fixed constant for every column.
`parameterized_type!` is the parameterized counterpart: persisted length is
computed per column from the declared parameters, through `int_to_params`
and `resolve_params`, instead of being fixed.

```rust theme={null}
villagesql::parameterized_type!(
    type_name: "type_name_in_sql",
    max_persisted_length: N,
    max_decode_buffer_length: M,
    encode: your_encode_fn,
    decode: your_decode_fn,
    compare: your_compare_fn,
    int_to_params: your_int_to_params_fn,
    resolve_params: your_resolve_params_fn,
    params_type: YourParamsType,
    params_parse: your_params_parse_fn,
    params_to_strings: your_params_to_strings_fn,
    hash: your_hash_fn,
    default: "a_valid_string_literal",
)
```

| Field                      | Type           | Description                                                                                                         |
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `type_name`                | string literal | The SQL type name. Case-insensitive in SQL.                                                                         |
| `max_persisted_length`     | `i64`          | Upper bound on the on-disk byte length, across every valid parameterization.                                        |
| `max_decode_buffer_length` | `usize`        | Maximum byte length of the decoded string representation.                                                           |
| `encode`                   | fn             | Converts a `&str` plus `&mut MaybeParams<P>` to binary bytes at INSERT time.                                        |
| `decode`                   | fn             | Converts binary bytes plus `&P` back to a `String` for display.                                                     |
| `compare`                  | fn             | Returns `Ordering` for `ORDER BY`, `MIN`, `MAX`, given `&P`.                                                        |
| `int_to_params`            | fn             | Converts the `TYPE(N)` integer syntax into a canonical params string.                                               |
| `resolve_params`           | fn             | Validates a parsed `Params` and returns a `Resolved` (storage sizes, optionally rewritten params).                  |
| `params_type`              | type           | Your parsed-parameter struct, e.g. `PadintParams`.                                                                  |
| `params_parse`             | fn             | Parses `Params` into `params_type`. Runs at most once per unique parameter combination — the SDK caches the result. |
| `params_to_strings`        | fn             | The inverse of `params_parse`: writes `params_type` back into canonical `(key, value)` pairs.                       |
| `hash`                     | fn             | Returns a `usize` hash given `&P`. Optional but recommended for indexed columns.                                    |
| `default`                  | string literal | A valid string the server can encode at type initialization. Optional but recommended.                              |

`type_name`, `max_persisted_length`, `max_decode_buffer_length`, `encode`, `decode`, `compare`, `int_to_params`, `resolve_params`, `params_type`, `params_parse`, and `params_to_strings` are required. `hash` and `default` are optional; `intrinsic_default_fn` (a function computing the default from `&P`, for when the default depends on parameters) is also optional and mutually exclusive with `default`.

Here's `padint` — an `i64` stored in a fixed 8 bytes, with a `width` parameter controlling zero-padded display width:

```rust theme={null}
pub struct PadintParams {
    pub width: u32,
}

pub fn padint_parse(params: Params) -> PadintParams {
    let width = params.get("width").and_then(|s| s.parse().ok()).unwrap_or(1);
    PadintParams { width }
}

pub fn padint_to_strings(p: &PadintParams) -> Vec<(String, String)> {
    vec![("width".to_string(), p.width.to_string())]
}

pub fn padint_int_to_params(n: i64) -> Result<String, String> {
    if n < 1 {
        return Err(format!("padint: width must be >= 1, got {n}"));
    }
    Ok(format!("width={n}"))
}

pub fn padint_resolve_params(params: Params) -> Result<Resolved, String> {
    let width: i64 = params
        .get("width")
        .ok_or_else(|| "padint: missing 'width' param".to_string())?
        .parse()
        .map_err(|e| format!("padint: bad width: {e}"))?;
    // Fixed 8-byte i64 storage; the decode buffer must fit the widest
    // rendering, which is the larger of an i64's digits and the padded width.
    Ok(Resolved::new(8, width.max(20)))
}

pub fn padint_encode(s: &str, params: &mut MaybeParams<PadintParams>) -> Result<Vec<u8>, String> {
    let n: i64 = s.trim().parse().map_err(|e| format!("padint: {e}"))?;
    if !params.is_known() {
        // Bare constant with no column to anchor it: infer width from digits.
        let digits = s.trim().trim_start_matches('-').len().max(1);
        params.set(PadintParams { width: u32::try_from(digits).unwrap_or(u32::MAX) });
    }
    Ok(n.to_le_bytes().to_vec())
}

pub fn padint_decode(b: &[u8], p: &PadintParams) -> Result<String, String> {
    let n = i64::from_le_bytes(b[..8].try_into().unwrap());
    Ok(format!("{n:0width$}", width = p.width as usize))
}

pub fn padint_compare(a: &[u8], b: &[u8], _p: &PadintParams) -> std::cmp::Ordering {
    i64::from_le_bytes(a[..8].try_into().unwrap()).cmp(&i64::from_le_bytes(b[..8].try_into().unwrap()))
}

villagesql::parameterized_type!(
    type_name: "padint",
    max_persisted_length: 8,
    max_decode_buffer_length: 32,
    encode: padint_encode,
    decode: padint_decode,
    compare: padint_compare,
    int_to_params: padint_int_to_params,
    resolve_params: padint_resolve_params,
    params_type: PadintParams,
    params_parse: padint_parse,
    params_to_strings: padint_to_strings,
    default: "0",
)
```

A function receiving a parameterized custom-type argument sees
`InValue::CustomWithParams { bytes, params }` rather than plain
`InValue::Custom(bytes)` — `params` is a \[`TypeParams`], a read-only,
zero-copy view over the column's declared `key=value` pairs.

## The extension! block with types

When registering both functions and types, the `extension!` block has two sections:

```rust theme={null}
villagesql::extension! {
    funcs: [
        // VDFs declared with func!
    ],
    types: [
        // Custom types declared with custom_type!
    ]
}
```

A function-only extension omits `types:`. A type-only extension keeps `funcs: []` and omits nothing else.

## Next steps

<CardGroup cols={2}>
  <Card title="Rust API Reference" icon="book" href="/docs/mysql-9.7/dev/rust-api-reference">
    Complete reference for InValue, VdfReturn, and all macros.
  </Card>

  <Card title="Creating Extensions in Rust" icon="wrench" href="/docs/mysql-9.7/dev/rust-sdk">
    Getting started — Cargo setup, first function, packaging, and testing.
  </Card>

  <Card title="C++ Custom Types" icon="shapes" href="/docs/mysql-9.7/dev/custom-types">
    Custom types in C++ — `make_type<>`, encode/decode/compare/hash, ALTER TABLE rules.
  </Card>

  <Card title="Extension Architecture" icon="sitemap" href="/docs/mysql-9.7/dev/architecture">
    How custom types are resolved, cached, and stored.
  </Card>
</CardGroup>
