Skip to content
djust
Appearance
Mode

Type-Safe Template Validation

Python's dynamic typing makes template-variable bugs the #1 source of "it rendered blank and I don't know why" issues. manage.py djust_typecheck reads every LiveView template, extracts every variable reference, and flags names that the view can't possibly provide.

No TypeScript. No type stubs. No build step. Just a management command you run locally or in CI.

Running the check

python manage.py djust_typecheck

Example output:

djust_typecheck: 2 view(s) with unresolved names

myapp.views.CheckoutView (checkout.html)
    line 14: cartt
    line 22: total_amount

myapp.views.ProfileView (profile.html)
    line 7: usre

The command walks every LiveView subclass in the project, resolves each view's template_name via Django's template loaders, and looks for any variable or tag reference that isn't covered by the view's declared context.

Options

python manage.py djust_typecheck --json            # machine-readable JSON
python manage.py djust_typecheck --app myapp       # one Django app
python manage.py djust_typecheck --view CheckoutView  # one view
python manage.py djust_typecheck --strict          # exit non-zero on findings

Add it to CI with --strict:

# .github/workflows/ci.yml
- name: Template typecheck
  run: python manage.py djust_typecheck --strict

What counts as "declared"

A reference resolves against the union of:

  1. Public class attributes — anything set on the class body (or inherited from user mixins) with a non-underscore name.
  2. self.foo = ... assignments — anywhere in the class (mount, event handlers, helpers). AST-extracted without running the code.
  3. @property decorated methods — treated as attributes.
  4. Literal return {...} in get_context_data — keys from a return {"foo": ...} or return {**super, "foo": ...} literal.
  5. Template-local names{% for x in ... %}, {% with x=... %}, and {% inputs_for fs as form %} all bind identifiers the rest of the block may use.
  6. Framework built-insuser, request, perms, csrf_token, messages, forloop, djust, is_dirty, changed_fields, async_pending, etc.
  7. Project-wide globals — anything listed in settings.DJUST_TEMPLATE_GLOBALS.

Example:

# settings.py
DJUST_TEMPLATE_GLOBALS = ["site_title", "current_year", "feature_flags"]

Context processors that inject values into every template should be listed here — otherwise the checker will flag every reference to them.

Silencing false positives

The checker is intentionally conservative — anything it can't statically resolve gets flagged. Three ways to silence:

Per-template pragma

{# djust_typecheck: noqa #}

Silences the entire template. Use noqa name1, name2 to silence specific names only:

{# djust_typecheck: noqa dynamic_section, late_bound_flag #}

Per-view opt-in to strict mode

class CheckoutView(LiveView):
    strict_context = True
    template_name = "checkout.html"

strict_context = True makes that view's findings count as errors regardless of the global --strict flag. Good for "this view is fully typed; regress at your peril."

Global globals list

# settings.py
DJUST_TEMPLATE_GLOBALS = ["navbar", "breadcrumbs"]

For names injected by base templates, context processors, or framework plumbing outside djust.

Tag coverage

Beyond the obvious {{ var }} / {% if %} / {% for %} references, the extractor follows positional context-variable arguments inside several Django built-in tags so the checker reports neither false-positives on tag-local names nor false-negatives on positional references. Coverage as of v0.5.2:

TagWhat's extractedWhat's ignored
{% firstof a b "literal" %}a, b (positional refs)string literals
{% cycle a b c as label %}a, b, c (positional refs)as <label> suffix
{% blocktrans with x=expr count y=qty %}expr, qty (the references)x, y (template-local bindings, scoped to the block)
{% blocktranslate %}same as blocktranssame
{% with name=expr %}exprname (block-local)
{% include "path" with k=v only %}vk, only
{% url "name" arg1 arg2 %}arg1, arg2the URL name string

Adding tag coverage is mostly a question of teaching python/djust/management/commands/djust_typecheck.py what each tag's arguments mean — the extractor is regex-based and incremental. File an issue if you hit a built-in or third-party tag whose arguments should be tracked.

Known limitations

  • Dynamic dict returnsget_context_data that builds its dict in a loop, via dict(...), or with update() calls is not followed. Declare the keys on self instead, or list them in DJUST_TEMPLATE_GLOBALS.
  • {% include %} scope — the included template's variables aren't cross-checked against the including view's context. Include templates should be checked independently if they also belong to a LiveView.
  • Filter argument evaluation{{ foo|default:bar }} is treated as a reference to foo only (the bar argument is not statically extracted yet).
  • Component slotsLiveComponent templates that reference slot content are not traced back to the parent's context.

The checker errs on the side of false positives over false negatives — a flagged name is either a real bug or a name you should tell the checker about (via noqa, DJUST_TEMPLATE_GLOBALS, or a class attribute).

Why this is a differentiator

Neither Phoenix nor React catches template-variable typos statically without an external type system (TypeScript, Flow). djust is the first Python-side LiveView-style framework with a first-party static template check. The tradeoff: accepting some false positives in exchange for catching real bugs before they reach users — most projects find the first run worth it.