|
| 1 | +>>>markdown |
| 2 | +## Types and Virtual Sources |
| 3 | + |
| 4 | +Malloy's `type:` declaration and `::` type operator introduce two related capabilities: |
| 5 | + |
| 6 | +- **Type declarations on table sources** make a source self-describing — the schema lives in the `.malloy` file, not just the database. An LLM can reason about the source without a connection, a CI pipeline can validate without credentials, and a human can see the fields without running a query. |
| 7 | + |
| 8 | +- **Virtual sources** define a source with no underlying table. The type declaration *is* the schema. The actual table is resolved at query time through a mapping, making it possible to write models against tables that don't exist yet or to point the same model at different tables in different environments. |
| 9 | + |
| 10 | +This is an experimental feature. Enable it with `##! experimental.virtual_source` at the top of your `.malloy` file. |
| 11 | + |
| 12 | +See [WN-0024 (Type Declarations and Virtual Sources)](https://github.com/malloydata/whatsnext/blob/main/wns/WN-0024-schema-and-virtual-sources/wn-0024.md) for the full design. |
| 13 | + |
| 14 | +### Declaring Types |
| 15 | + |
| 16 | +A `type:` declaration is a top-level statement, like `source:` or `query:`. It defines a named collection of typed fields: |
| 17 | + |
| 18 | +```malloy |
| 19 | +##! experimental.virtual_source |
| 20 | + |
| 21 | +type: store is { |
| 22 | + id :: number, |
| 23 | + name :: string, |
| 24 | + street :: string, |
| 25 | + city :: string, |
| 26 | + state :: string |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +A type has no connection to any database and no behavior on its own. It becomes meaningful when applied to a source. |
| 31 | + |
| 32 | +### Supported Types |
| 33 | + |
| 34 | +Type declarations support the full range of Malloy types: |
| 35 | + |
| 36 | +| Type | Example | |
| 37 | +|------|---------| |
| 38 | +| Basic types | `x :: string`, `y :: number`, `z :: boolean`, `d :: date`, `t :: timestamp` | |
| 39 | +| Timestamp with timezone | `t :: timestamptz` | |
| 40 | +| Arrays | `tags :: string[]`, `scores :: number[]` | |
| 41 | +| Inline records | `address :: { street :: string, city :: string }` | |
| 42 | +| Named types | `location :: address` | |
| 43 | +| Arrays of records | `items :: { name :: string, qty :: number }[]` | |
| 44 | +| SQL native types | `big_id :: "BIGINT"`, `geo :: "GEOGRAPHY"` | |
| 45 | + |
| 46 | +SQL native types are written as quoted strings, for database-specific types that don't have a Malloy equivalent. |
| 47 | + |
| 48 | +### Composing Types |
| 49 | + |
| 50 | +Types can reference other named types, allowing nested structures to be composed from reusable pieces: |
| 51 | + |
| 52 | +```malloy |
| 53 | +type: address is { |
| 54 | + street :: string, |
| 55 | + city :: string, |
| 56 | + state :: string, |
| 57 | + zip :: string |
| 58 | +} |
| 59 | + |
| 60 | +type: store is { |
| 61 | + id :: number, |
| 62 | + name :: string, |
| 63 | + location :: address |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +A type can also incorporate the fields of another type using `extend`: |
| 68 | + |
| 69 | +```malloy |
| 70 | +type: cigar_store is store extend { |
| 71 | + humidor_capacity :: number, |
| 72 | + walk_in :: boolean |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +This produces a type with all the fields of `store` plus the new fields. There is no subtype relationship — the result is a flat bag of fields. |
| 77 | + |
| 78 | +### Applying Types to Table Sources |
| 79 | + |
| 80 | +The `::` type operator on a source expression declares the **complete expected schema** of that source: |
| 81 | + |
| 82 | +```malloy |
| 83 | +##! experimental.virtual_source |
| 84 | + |
| 85 | +type: airport_fields is { |
| 86 | + code :: string, |
| 87 | + city :: string, |
| 88 | + state :: string, |
| 89 | + elevation :: number |
| 90 | +} |
| 91 | + |
| 92 | +source: airports is duckdb.table('airports.parquet')::airport_fields |
| 93 | +``` |
| 94 | + |
| 95 | +When applied to a table source, the type: |
| 96 | + |
| 97 | +1. **Hides undeclared fields.** Columns not listed in the type are marked `hidden`. They still exist — joins and expressions can reference them — but they won't appear in query output or auto-complete. |
| 98 | + |
| 99 | +2. **Validates types.** If a declared field exists but its type doesn't match the actual column, the compiler reports an error. |
| 100 | + |
| 101 | +3. **Validates existence.** If a declared field doesn't exist in the table at all, the compiler reports an error. |
| 102 | + |
| 103 | +Dimensions, measures, and joins defined in an `extend` block are unaffected — the type only governs intrinsic (table-derived) fields. |
| 104 | + |
| 105 | +#### Multiple Types |
| 106 | + |
| 107 | +You can apply several types at once with parentheses: |
| 108 | + |
| 109 | +```malloy |
| 110 | +type: id_fields is { id :: number, name :: string } |
| 111 | +type: metric_fields is { revenue :: number, cost :: number } |
| 112 | + |
| 113 | +source: report is duckdb.table('report.parquet')::(id_fields, metric_fields) |
| 114 | +``` |
| 115 | + |
| 116 | +This is equivalent to creating a single type that extends the others. |
| 117 | + |
| 118 | +#### Narrowing a Source |
| 119 | + |
| 120 | +Because `::` always means "the complete expected schema," you can narrow an existing source by applying a smaller type: |
| 121 | + |
| 122 | +```malloy |
| 123 | +##! experimental.virtual_source |
| 124 | + |
| 125 | +type: full_schema is { id :: number, name :: string, city :: string } |
| 126 | +type: narrow_schema is { id :: number, name :: string } |
| 127 | + |
| 128 | +source: a is duckdb.virtual('x')::full_schema |
| 129 | + |
| 130 | +// b has all of a's fields, but only id and name are public |
| 131 | +source: b is a::narrow_schema |
| 132 | +``` |
| 133 | + |
| 134 | +### Virtual Sources |
| 135 | + |
| 136 | +A virtual source uses `connection.virtual('name')` instead of `connection.table('path')`. There is no underlying table — the type *defines* the fields: |
| 137 | + |
| 138 | +```malloy |
| 139 | +##! experimental.virtual_source |
| 140 | + |
| 141 | +type: user_facts_fields is { |
| 142 | + user_id :: string, |
| 143 | + signup_date :: date, |
| 144 | + lifetime_value :: number, |
| 145 | + segment :: string |
| 146 | +} |
| 147 | + |
| 148 | +source: user_facts is duckdb.virtual('user_facts')::user_facts_fields |
| 149 | +``` |
| 150 | + |
| 151 | +Virtual sources can be extended like any other source: |
| 152 | + |
| 153 | +```malloy |
| 154 | +source: user_facts_model is duckdb.virtual('user_facts')::user_facts_fields extend { |
| 155 | + dimension: signup_year is year(signup_date) |
| 156 | + measure: total_ltv is lifetime_value.sum() |
| 157 | + measure: user_count is count() |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +#### Resolving Virtual Sources at Query Time |
| 162 | + |
| 163 | +At query time, the application provides a **virtual map** that resolves virtual names to actual table paths. For example, the same model can point at different tables in different environments: |
| 164 | + |
| 165 | +```malloy |
| 166 | +##! experimental.virtual_source |
| 167 | + |
| 168 | +type: feature_fields is { user_id :: string, feature_vec :: string } |
| 169 | + |
| 170 | +// The model is the same everywhere |
| 171 | +source: features is bq.virtual('features')::feature_fields |
| 172 | + |
| 173 | +// Dev: bq → features → "dev.features_sample" |
| 174 | +// Prod: bq → features → "prod.ml_features_v3" |
| 175 | +// Test: bq → features → "test_fixtures.features_small" |
| 176 | +``` |
| 177 | + |
| 178 | +If a virtual source is used in a query and no map entry exists, the compiler produces an error — there is no fallback, because a virtual source has no SQL of its own. |
| 179 | + |
| 180 | +### Summary |
| 181 | + |
| 182 | +| Source Type | Type Behavior | Fields Come From | |
| 183 | +|-------------|---------------|------------------| |
| 184 | +| `table()` without `::` | All table columns are public | Database | |
| 185 | +| `table()::type` | Declared columns public, rest hidden, types validated | Database (validated) | |
| 186 | +| `virtual()::type` | Declared columns are the only fields | Type declaration | |
| 187 | +| `virtual()` without `::` | Empty source (legal but useless) | Nothing | |
| 188 | +>>>markdown |
0 commit comments