Interpolation Type Checking¶
Interpolation type checking is an opt-in LSP feature that checks values inserted
into typed JSON, YAML, TOML, and psycopg SQL template strings, plus TDOM
component props. It uses each template backend to decide what Python type an
interpolation position accepts, asks Ty, Pyright, or Pyrefly to check that
expression, and reports the result back on the original {expression}.
The feature is intentionally separate from t-linter check. The CLI still runs
the built-in template syntax checks only; interpolation value diagnostics require
an LSP session and a running type checker language server.
Architecture¶
The type checker has five parts:
| Layer | Responsibility |
|---|---|
| Parser and language resolver | Finds PEP 750 template strings and resolves their embedded language from annotations, aliases, function parameters, and supported callees. |
| Template backend | Computes contextual interpolation type requirements for a language. JSON, YAML, TOML, psycopg SQL, and TDOM component props currently return requirements. |
| SQL catalog cache | Optionally supplies PostgreSQL parameter types for psycopg SQL templates prepared with t-linter sql prepare. |
| Shadow document synthesizer | Creates an in-memory Python document that preserves the original line count and adds type-check assignments for the selected checker. |
| Type checker client | Starts and reuses a dedicated Ty, Pyright, or Pyrefly language server, syncs the shadow document, and collects diagnostics. |
| Diagnostic remapper | Filters backend-specific assignment errors and maps them back to the original interpolation ranges. |
The data flow is:
open Python document
-> parse template strings and resolve template languages
-> ask the JSON/YAML/TOML/SQL backend or TDOM component-prop backend for interpolation requirements
-> read SQL catalog cache entries when a psycopg SQL parameter can be narrowed from PostgreSQL metadata
-> synthesize same-line annotated assignments in a shadow Python document
-> sync that shadow document to a dedicated type checker server
-> collect pull or publish diagnostics from that server
-> keep assignment diagnostics that intersect generated RHS ranges
-> publish t-linter diagnostics on the original interpolation expressions
The external type checker never sees the user's editor buffer directly. It sees a shadow copy owned by t-linter's LSP server. The shadow copy uses the same URI and a separate language server process, so it does not affect other Python language servers attached to the editor.
Shadow Document Strategy¶
The implementation appends annotated assignments at the end of the same Python simple statement that contains the template. This is the core trick that lets the selected checker check arbitrary interpolation expressions without changing the source file.
Original source:
from typing import Annotated
from string.templatelib import Template
class User:
name: str
def send(template: Annotated[Template, "json"]) -> None:
...
def handler(user: User, age: int) -> None:
send(t'{{"name": {user}, "label": "{age}", "age": {age}}}')
Shadow source sent to the selected checker:
from typing import Annotated
from string.templatelib import Template
class User:
name: str
def send(template: Annotated[Template, "json"]) -> None:
...
def handler(user: User, age: int) -> None:
send(t'{{"name": {user}, "label": "{age}", "age": {age}}}'); __tl_0_0: "str | int | float | bool | None | dict[str, object] | list[object]" = user; __tl_0_1: "str" = age; __tl_0_2: "str | int | float | bool | None | dict[str, object] | list[object]" = age
If the checker reports that assigning user to the generated JSON value type is
invalid, t-linter publishes one warning on {user} in the original template.
The generated names and assignments are never written to disk.
When a backend requirement mentions datetime.date, datetime.time, or
datetime.datetime, the shadow synthesizer inserts
; import datetime as __tl_datetime before the generated assignments and
rewrites those annotations through the collision-free alias. This keeps the
shadow document parseable without adding imports that would shift line numbers.
Earlier prototypes used generated helper calls. That approach was too context-sensitive: class bodies, nested statements, indentation, and line mapping all required special cases. The current same-line assignment strategy keeps the shadow document parseable in class bodies and nested functions, avoids line shifts, and gives each checked expression a stable RHS byte range that can be matched against type checker diagnostics.
Backend Requirements¶
Backends own the logic for their DSL. t-linter does not hard-code JSON, YAML, TOML, SQL, or TDOM AST traversal rules in the LSP layer.
Each backend returns an InterpolationTypeRequirement containing:
- the interpolation index in the template
- a Python type expression that the selected checker can evaluate
- a human-readable description such as
json value,yaml mapping key, ortoml string fragment
Examples of backend decisions:
| Template context | Expected Python annotation |
|---|---|
| JSON object key | str |
| JSON string fragment | str |
| JSON value | str \| int \| float \| bool \| None \| dict[str, object] \| list[object] |
| YAML mapping key or value | str \| int \| float \| bool \| None \| datetime.date \| datetime.time \| datetime.datetime \| list[object] \| dict[object, object] |
| YAML scalar or metadata fragment | str |
| TOML key | str |
| TOML string fragment | str |
| TOML value | str \| int \| float \| bool \| datetime.date \| datetime.time \| datetime.datetime \| list[object] \| dict[str, object] |
psycopg SQL parameter {value} or {value:l} |
bool \| int \| float \| decimal.Decimal \| str \| bytes \| datetime.date \| datetime.datetime \| datetime.time \| datetime.timedelta \| uuid.UUID \| list \| psycopg.types.json.Json \| psycopg.types.json.Jsonb \| None |
psycopg SQL parameter with catalog cache, such as {id} in WHERE id = {id} where id is int4 |
int |
psycopg identifier {value:i} |
str \| psycopg.sql.Identifier |
psycopg SQL fragment {value:q} |
string.templatelib.Template \| psycopg.sql.SQL \| psycopg.sql.Composed |
TDOM component prop title={value} where title: str |
str |
TDOM component prop items={value} where items: list[str] |
list[str] |
TDOM component prop string fragment label="Hi {name}" |
str |
HTML and T-HTML currently return no interpolation type requirements. TDOM
returns requirements only for component prop interpolations whose component
signature can be resolved by t-linter. SQL returns requirements only for
psycopg templates resolved from supported call chains or enabled with
[tool.t-linter.sql] library = "psycopg". Tree-sitter-only languages such as
CSS and JavaScript are not part of this mechanism.
For catalog-backed psycopg SQL narrowing, run t-linter sql prepare . to
populate .t-linter/sql-cache/ from a configured PostgreSQL database. The LSP
then reads the committed cache and can report type diagnostics without opening a
database connection. t-linter sql prepare --check . compares the committed
cache with the current schema and exits with status 2 if it is stale. If the
configured database URL resolves but the live describe call cannot reach
PostgreSQL, --check falls back to the existing committed cache and exits
successfully.
For CLI usage, set T_LINTER_SQL_PYTHON to a Python interpreter with psycopg
installed, or run t-linter sql prepare from an environment whose PATH
already resolves such a Python. See SQL Catalog Cache
for configuration, cache workflow, CI behavior, and an end-to-end example.
Runtime Behavior¶
Interpolation type checking is disabled by default. Enable it through LSP initialization options:
{
"typeChecking": {
"enabled": true,
"checker": "pyright",
"command": "/path/to/pyright-langserver",
"args": ["--stdio"]
}
}
For VS Code, use:
{
"t-linter.typeChecking.enabled": true,
"t-linter.typeChecking.checker": "pyright",
"t-linter.typeChecking.command": "/path/to/pyright-langserver"
}
checker defaults to ty. When command and args are omitted, t-linter uses
the selected checker defaults:
checker |
Default command |
|---|---|
ty |
ty server |
pyright |
pyright-langserver --stdio |
pyrefly |
pyrefly lsp |
t-linter searches the active virtual environment or conda environment,
workspace .venv or venv, uv run for uv projects, and finally the selected
checker on PATH. The legacy VS Code t-linter.typeChecking.tyPath setting is
still honored only when checker is ty.
The LSP diagnostic loop first publishes normal template diagnostics. If interpolation type checking is enabled and the document contains supported requirements, it then starts or reuses the selected checker, syncs the shadow document, collects diagnostics, and publishes a merged diagnostic set for the same document version. If the document changed while the checker was running, stale type diagnostics are discarded.
Published type diagnostics use:
| Field | Value |
|---|---|
source |
t-linter (ty), t-linter (pyright), or t-linter (pyrefly) |
code |
interpolation-type-error |
severity |
warning |
| range | the original interpolation expression, not the generated assignment |
Skipped Cases¶
t-linter skips an interpolation when checking it would require changing Python semantics or producing unstable source mapping:
- the template language is unsupported or untyped
- the backend reports no requirement for that interpolation
- the interpolation uses a conversion such as
{value!r} - the interpolation uses a format spec such as
{value:.2f} - the interpolation expression spans multiple lines
- the interpolation contains a named expression such as
{(value := make())} - the template is outside a supported Python simple statement
Skipping one interpolation does not disable the whole document. Other safe interpolations in the same file are still checked.
Type Checker Process Lifecycle¶
t-linter starts the selected checker lazily on the first document that needs type checking. The server is reused across diagnostics in the same LSP session.
Startup is guarded so concurrent diagnostics do not launch multiple checker processes unnecessarily. The external process is started outside the shared state lock, so a slow checker startup does not block close or shutdown paths. Before reusing a cached client, t-linter checks whether the child process is still running; if it exited, the cache is cleared and startup is retried.
After repeated startup failures, t-linter disables interpolation type checking for the session and logs a warning through LSP. Request and response handling use JSON-RPC over stdin/stdout with a 10 second request timeout.
t-linter advertises both UTF-8 and UTF-16 position encodings to the selected
checker, records the negotiated encoding from initialize, and uses that
encoding when converting diagnostic ranges back to byte ranges in the shadow
document. Ty diagnostics are read through textDocument/diagnostic; Pyright and
Pyrefly diagnostics are read from textDocument/publishDiagnostics.
Implementation Map¶
The relevant implementation files are:
| File | Role |
|---|---|
crates/t-linter-core/src/backend.rs |
Normalizes language names and delegates syntax, formatting, and interpolation requirement calls to tstring-* backends. |
crates/t-linter-core/src/sql/catalog.rs |
Builds normalized SQL catalog queries, reads and writes cache entries, resolves database URLs, and translates describe responses into cache metadata. |
crates/t-linter-core/src/shadow.rs |
Builds ShadowDocument and ShadowCheckSite values, inserts same-line assignments, skips unsafe expressions, and preserves line counts. |
crates/t-linter-cli/src/sql_prepare.rs |
Implements t-linter sql prepare, starts the Python describe helper, and writes or checks catalog cache files. |
crates/t-linter-lsp/src/type_checker.rs |
Manages TypeCheckerConfig, TypeCheckerClient, checker discovery, startup, JSON-RPC, document sync, diagnostic collection, and shutdown. |
crates/t-linter-lsp/src/sql_catalog.rs and crates/t-linter-lsp/helpers/sql_describe.py |
Describe psycopg SQL queries through a Python helper when live metadata is needed. |
crates/t-linter-lsp/src/lib.rs |
Runs the LSP diagnostic pipeline, calls shadow synthesis, filters backend assignment diagnostics, remaps diagnostics, and merges the final publish payload. |
crates/t-linter/tests/type_check_lsp.rs |
End-to-end coverage against real Ty, Pyright, and Pyrefly binaries for JSON, YAML, TOML, TDOM component prop remapping, and SQL catalog cache diagnostics. |
The backend crates expose their requirements through
interpolation_type_requirements or a resolver-backed variant such as
tstring_tdom::interpolation_type_requirements_with_component_props. Adding
another DSL to this mechanism should start in that DSL backend, not in the LSP
remapper. The LSP layer should remain language-agnostic: it consumes requirement
records, synthesizes Python assignments, and maps checker diagnostics back to
source locations.