Skip to content

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, or toml 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.