Critical syntax rules and patterns for AI agents generating NTNT code. For complete reference documentation, see:
- STDLIB_REFERENCE.md - All functions and modules
- SYNTAX_REFERENCE.md - Keywords, operators, types, templates
- IAL_REFERENCE.md - Intent Assertion Language
Always lint before run:
ntnt lint myfile.tnt # Catches 90% of errors
ntnt run myfile.tnt # Only after lint passes
# For HTTP servers - automated testing
ntnt test server.tnt --get /health --post /users --body 'name=Alice'IDD is the core workflow for NTNT development. You capture user requirements as executable specifications, then implement code that satisfies them.
When a user describes what they want to build, your job is to:
- Listen for testable assertions (what should happen, what users should see)
- Capture these as Glossary terms and Scenarios in a
.intentfile - Present the intent file for user approval before writing code
- Implement with
@implementsannotations - Verify continuously with
ntnt intent checkor Intent Studio
| Step | Action | User Input | What You Do |
|---|---|---|---|
| 1 | Listen to requirements | User describes features | Extract testable behaviors |
| 2 | Draft .intent file |
No | Create Glossary + Features + Scenarios |
| 3 | Present to user | YES - STOP HERE | Show the intent file, ask for feedback |
| 4 | Refine based on feedback | Yes | Update Glossary and Scenarios |
| 5 | User approves | YES | Get explicit approval before coding |
| 6 | Generate scaffolding | No | Run ntnt intent init (optional) |
| 7 | Implement with @implements |
No | Write code, link to features |
| 8 | Verify with Intent Studio | No | Run ntnt intent studio for live feedback |
| 9 | Final check | No | Run ntnt intent check to confirm all passing |
When users describe what they want, listen for:
| User Says | Capture As |
|---|---|
| "The home page should show a welcome message" | Scenario: they see "Welcome" |
| "Users need to log in" | Feature: User Login |
| "The API returns JSON" | Glossary: returns JSON → content-type is json |
| "It should be fast" | Glossary: responds quickly → response time < 500ms |
| "Only admins can delete" | Constraint: Admin Only, applies_to features |
The Glossary is your domain-specific vocabulary. Build it from how the user naturally describes things:
## Glossary
| Term | Means |
|------|-------|
# Navigation terms (how users describe going places)
| a user visits {path} | GET {path} |
| a visitor goes to {path} | GET {path} |
| the home page | / |
| the login page | /login |
| the dashboard | /dashboard |
# Success terms (how users describe things working)
| the page loads | status 200 |
| it works | status 200 |
| succeeds | status 200 |
# Content terms (what users should see)
| they see {text} | body contains {text} |
| they don't see {text} | body not contains {text} |
| shows {text} | body contains {text} |
# API terms (for JSON APIs)
| returns JSON | content-type is json |
| returns the {field} | body has field {field} |
# Error terms
| shows an error | status 4xx |
| page not found | status 404 |
| unauthorized | status 401 |
# Performance terms
| responds quickly | response time < 500ms |Use the When → outcomes format with natural language that maps to your Glossary:
Feature: User Dashboard
id: feature.dashboard
description: "Authenticated users can view their dashboard"
Scenario: User views dashboard
When a user visits the dashboard
→ the page loads
→ they see "Welcome back"
→ they see "Recent Activity"
Scenario: Dashboard shows user data
When a user visits the dashboard
→ they see their username
→ they see their emailScenario naming tips:
- Use active voice: "User views dashboard" not "Dashboard is viewed"
- Be specific: "User sees welcome message" not "Page works"
- One scenario = one user story or behavior
# Project Name
# Run: ntnt intent check server.tnt
## Title
My Application Name
## Overview
Brief description of what this application does and who it's for.
## Glossary
| Term | Means |
|------|-------|
| a user visits {path} | GET {path} |
| the home page | / |
| the page loads | status 200 |
| they see {text} | body contains {text} |
| they don't see {text} | body not contains {text} |
---
Feature: Feature Name
id: feature.feature_name
description: "What this feature does for the user"
Scenario: Descriptive scenario name
When a user visits the home page
→ the page loads
→ they see "Expected content"
---
Constraint: Constraint Name
description: "Cross-cutting concern that applies to multiple features"
applies_to: [feature.feature_name, feature.other_feature]Link your code to intent features:
// @implements: feature.homepage
fn home_handler(req) {
return html("<h1>Welcome</h1>")
}
// @implements: feature.user_login
fn login_handler(req) {
// Login logic
}
// @utility - Helper function, not a feature
fn hash_password(password) {
// Utility code
}
// @internal - Implementation detail
fn validate_session(token) {
// Internal logic
}
// @infrastructure - Setup/config code
fn setup_database() {
// Database initialization
}
ntnt intent studio server.intent # Visual preview with live tests (RECOMMENDED)
ntnt intent check server.tnt # Run tests from command line
ntnt intent coverage server.tnt # Show which features have implementations
ntnt intent init server.intent # Generate code scaffolding from intentUse Intent Studio during development - it shows live pass/fail indicators as you code!
IAL supports unit testing individual functions using the call: keyword in glossary terms.
## Glossary
| Term | Means |
|------|-------|
# Unit test terms - MUST include source: to specify the .tnt file
| rounding {value} to 1dp | call: round_1dp({value}), source: myfile.tnt |
| extracting name from {data} | call: extract_name({data}), source: myfile.tnt |
| checking if {line} is valid | call: is_valid_line({line}), source: myfile.tnt |Key requirements:
call: function_name({params})- specifies the function to call with parameter placeholderssource: filename.tnt- REQUIRED - specifies which .tnt file contains the function- Parameters in
{braces}are captured from the When clause and substituted
Feature: Decimal Rounding
id: feature.unit_round_1dp
description: "Round values to one decimal place for display"
Scenario: Rounds down correctly
When rounding 45.24 to 1dp
→ result is "45.2"
Scenario: Rounds up correctly
When rounding 45.25 to 1dp
→ result is "45.3"
Scenario: Handles negative values
When rounding -5.67 to 1dp
→ result is "-5.7"| Assertion | Description |
|---|---|
result is {value} |
Exact equality check |
result equals {value} |
Exact equality check (alias) |
result.field is {value} |
Check a field on a map result |
result is true / result is false |
Boolean checks |
## Glossary
| Term | Means |
|------|-------|
| validating email {email} | call: is_valid_email({email}), source: validators.tnt |
| formatting date {date} | call: format_date({date}), source: utils.tnt |
---
Feature: Email Validation
id: feature.unit_email_validation
description: "Validate email address format"
Scenario: Accepts valid email
When validating email "user@example.com"
→ result is true
Scenario: Rejects email without @
When validating email "userexample.com"
→ result is false
Scenario: Rejects empty string
When validating email ""
→ result is false- Array parameters: Complex types like
[1, 2, 3]may not parse correctly as function arguments - Nested results: Deep field access like
result.nested.fieldmay have issues - Keep unit test parameters simple (strings, numbers, booleans)
// CORRECT
let user = map { "name": "Alice", "age": 30 }
let empty = map {}
// Nested maps are inferred automatically
let config = map {
"server": { "host": "localhost", "port": 8080 }
}
// WRONG - {} is a block, not a map
let user = { "name": "Alice" }
// CORRECT
let msg = "Hello, {name}!"
// WRONG
let msg = "Hello, ${name}!"
let msg = `Hello, ${name}!`
// Route builtins auto-detect {param} as route parameters — no raw strings needed
get("/users/{id}", handler)
post("/api/{category}/items/{id}", handler)
// Raw strings still work (backward compatible)
get(r"/users/{id}", handler)
// CORRECT
fn divide(a: Int, b: Int) -> Int
requires b != 0
ensures result * b == a
{
return a / b
}
// WRONG - contracts in wrong position
fn divide(a: Int, b: Int) -> Int {
requires b != 0 // Inside body - wrong!
}
// CORRECT
for i in 0..10 { } // 0-9 exclusive
for i in 0..=10 { } // 0-10 inclusive
// WRONG
for i in range(10) { } // range() doesn't exist
NTNT uses a consistent two-part access model:
- Dot notation reads properties and fields (accessing what's already there)
- Free functions transform data (computing new values)
- Pipe operator chains transformations left-to-right
// READING data → dot notation
req.method // read a property
req.path // read a property
req.params.id // read a map key (static key)
req.params["id"] // read a map key (bracket form — required for dynamic keys or keys with special chars)
req.headers["content-type"] // bracket form for hyphenated keys
user.name // read a struct field
config.port // read a struct field
// TRANSFORMING data → free functions
len("hello") // compute a value from input
split(text, ",") // create a new array from a string
trim(input) // create a new string
push(arr, item) // create a new array with item added
int(form.age) // convert a value to a new type
// WRONG - method-style calls on stdlib functions
"hello".len() // Use len("hello")
arr.push(item) // Use push(arr, item)
text.split(",") // Use split(text, ",")
When to use dot vs brackets on maps:
- Dot notation for static keys known at write time:
req.params.id - Bracket notation for dynamic keys or keys with special characters:
req.headers["content-type"],row[column_name]
Use the pipe operator |> for readable left-to-right data transformations:
import { split, join, trim, to_lower } from "std/string"
// Pipe passes left side as FIRST argument to right side
let result = " Hello World " |> trim |> to_lower |> split(" ") |> join("-")
// Equivalent to: join(split(to_lower(trim(" Hello World ")), " "), "-")
// Works with any function (builtin or user-defined)
fn double(x) { return x * 2 }
let n = 5 |> double // 10
// Extra arguments: x |> f(a, b) becomes f(x, a, b)
let parts = "a,b,c" |> split(",") // split("a,b,c", ",")
// CORRECT
let mut counter = 0
counter = counter + 1
// WRONG
let counter = 0
counter = 1 // ERROR: immutable
Use fn(params) { body } in expression position for inline callbacks:
// Single-expression body (implicit return)
let double = fn(x) { x * 2 }
// Multi-statement body
let process = fn(item) {
let cleaned = trim(item)
return to_lower(cleaned)
}
// With type annotations
let multiply = fn(a: Int, b: Int) -> Int { a * b }
// Inline with higher-order functions
let evens = filter(nums, fn(x) { x % 2 == 0 })
let doubled = transform(nums, fn(x) { x * 2 })
// Closures capture enclosing variables
let threshold = 10
let above = filter(nums, fn(x) { x > threshold })
// Nested closures (currying)
let make_adder = fn(x) { fn(y) { x + y } }
let add5 = make_adder(5)
print(add5(10)) // 15
// Immediate invocation
let result = fn(x) { x + 1 }(5) // 6
// WRONG - pipe-style lambdas don't exist
let f = |x| x * 2 // Use fn(x) { x * 2 }
Functions and lambdas support default values for parameters. Parameters with defaults must come after all required parameters:
// Basic default
fn greet(name = "World") {
return "Hello, {name}!"
}
greet() // "Hello, World!"
greet("Alice") // "Hello, Alice!"
// Multiple defaults — required params first
fn paginate(items, page = 1, per_page = 25) {
// items is required, page and per_page are optional
}
paginate("users") // page=1, per_page=25
paginate("users", 2) // page=2, per_page=25
paginate("users", 3, 10) // page=3, per_page=10
// With type annotations
fn add(a: Int, b: Int = 10) -> Int {
return a + b
}
// Defaults can reference earlier parameters
fn make_range(start = 0, end = start + 10) {
return "{start}..{end}"
}
make_range() // "0..10"
make_range(5) // "5..15"
// Works with contracts
fn divide(a, b = 1)
requires b != 0
{
return a / b
}
// WRONG - required params after defaults
fn bad(a = 1, b) { } // Parse error!
The type checker infers parameter types from default expressions when no type annotation is provided.
if/else can be used in expression position to return a value. Both branches are single expressions, and else is required:
// Basic if-expression
let x = if a > b { a } else { b }
// In function arguments
print(if debug { "verbose" } else { "summary" })
// In return statements
return if found { json(data) } else { not_found() }
// Else-if chains
let label = if x > 0 { "positive" } else if x == 0 { "zero" } else { "negative" }
// Nested
let result = if outer { if inner { 1 } else { 2 } } else { 3 }
// WRONG - else is required for if-expressions
let x = if true { 1 } // ERROR: If-expressions require an else branch
Map, array, and nested destructuring in let bindings, match, and for loops:
// Map destructuring
let { name, age } = map { "name": "Alice", "age": 30 }
// Rename fields
let { name: n } = map { "name": "Alice" }
// Nested destructuring
let { user: { name } } = map { "user": { "name": "Bob" } }
// Works with structs
struct User { name: String }
let u = User { name: "Eve" }
let { name } = u
// Array destructuring
let [a, b, c] = [1, 2, 3]
// Rest patterns with ...
let [first, ...rest] = [1, 2, 3, 4] // first=1, rest=[2,3,4]
let { name, ...other } = map { "name": "A", "age": 30 } // other={"age": 30}
// For-loop destructuring
import { entries } from "std/collections"
for [k, v] in entries(data) {
print("{k}={v}")
}
for { name } in users {
print(name)
}
// Map destructuring in match
match data {
{ name, age } => print("{name} is {age}"),
_ => print("no match")
}
Extract capture groups from regex matches:
import { capture_pattern, capture_all_pattern, capture_named_pattern } from "std/string"
// Single match with groups (index 0 = full match)
let groups = capture_pattern("Bear Lake (1042)", r"(.+) \((\d+)\)")
// groups = Some(["Bear Lake (1042)", "Bear Lake", "1042"])
// All matches with groups
let all = capture_all_pattern("2024-01 and 2025-02", r"(\d{4})-(\d{2})")
// all = [["2024-01", "2024", "01"], ["2025-02", "2025", "02"]]
// Named groups as map keys (use (?P<name>...) syntax)
let m = capture_named_pattern("2024-01-15", r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})")
// m = Some({"0": "2024-01-15", "year": "2024", "month": "01", "day": "15"})
CRITICAL: Routing functions are GLOBAL BUILTINS. Only response builders need importing.
// ONLY import response builders
import { json, html, parse_form, parse_json } from "std/http/server"
// Handler function (named functions recommended for routes)
// Use Request/Response types for fully typed handlers
fn get_user(req: Request) -> Response {
let id = req.params["id"]
return json(map { "id": id })
}
// Routes - global builtins, {param} auto-detected
get("/users/{id}", get_user)
post("/users", create_user)
// Static files
serve_static("/static", "./public")
// Server lifecycle
on_shutdown(fn() {
print("Cleaning up...")
})
listen(8080) // Starts with hot reload enabled
Request object properties (type Request — all fields typed):
req.method // String: "GET", "POST"
req.path // String: "/users/123"
req.params // Map<String, String>: route params
req.query_params // Map<String, String>: query string params
req.headers // Map<String, String>: headers map
req.body // String: raw body
req.ip // String: client IP (supports X-Forwarded-For)
req.id // String: request ID (from X-Request-ID or auto-generated)
Accessing request data (dot reads properties, brackets for dynamic/special keys):
req.params.id // dot for static keys
req.params["id"] // bracket form also works
req.query_params.page // dot for simple keys
req.headers["content-type"] // brackets required (hyphenated key)
Common mistakes:
// WRONG - Do NOT import routing functions
import { listen, get, post } from "std/http/server"
// WRONG - Pipe-style lambdas don't exist; use fn() syntax
get("/users/{id}", |req| { ... })
// OK (but named handlers preferred for routes for readability)
get("/health", fn(req) { json(map { "ok": true }) })
// WRONG - These don't exist as properties
req.json // Use parse_json(req) — a transform function
req.form // Use parse_form(req) — a transform function
For cleaner route definitions, use the server block syntax:
import { json, html } from "std/http/server"
fn home(req) { return html("<h1>Welcome</h1>") }
fn get_user(req) { return json(map { "id": req.params.id }) }
fn create_user(req) { return json(map { "created": true }, 201) }
fn admin_dashboard(req) { return html("<h1>Admin</h1>") }
fn logger(req) { print("Request: {req.method} {req.path}") }
server 8080 {
static "/assets" from "./public"
cors map { "origins": ["*"] }
middleware [logger]
GET / -> home
GET /users/{id: Int} -> get_user
POST /users -> create_user
group "/admin" {
middleware [require_admin]
GET / -> admin_dashboard
}
}
Key features:
- Typed route parameters:
{id: Int}validates the parameter is an integer, returning 400 Bad Request on type mismatch - Route groups:
group "/prefix" { ... }groups routes with a common prefix - Directives:
static,cors,middlewareconfigure server behavior - Route conflict detection: Ambiguous routes like
GET /users/{id}andGET /users/{name}are detected at startup
Typed parameters:
| Type | Validation |
|---|---|
Int |
Must be a valid integer |
Float |
Must be a valid float |
| (none) | String (no validation) |
| Variable | Values | Description |
|---|---|---|
NTNT_ENV |
production, prod |
Disables hot-reload for better performance |
NTNT_STRICT |
1, true |
Blocks execution on type errors (runs type checker before ntnt run) |
# Development (default) - hot-reload enabled
ntnt run server.tnt
# Production - hot-reload disabled
NTNT_ENV=production ntnt run server.tntHot-reload watches your .tnt files and imported modules for changes, automatically reloading on the next request. Disable in production for zero filesystem overhead per request.
All response builders are imported from std/http/server:
| Function | Description | Example |
|---|---|---|
json(data, status?) |
JSON response (default 200) | json(map { "ok": true }) |
html(content, status?) |
HTML response | html("<h1>Hello</h1>") |
text(content, status?) |
Plain text response | text("OK") |
redirect(url, status?) |
Redirect (default 302) | redirect("/login") |
status(code, body) |
Custom status with body | status(404, "Not found") |
not_found(body?) |
404 response | not_found("Page not found") |
error(body?) |
500 response | error("Server error") |
Low-level response function:
For full control, use response(status, headers, body):
import { response } from "std/http/server"
fn custom_handler(req) {
return response(
201,
map { "Content-Type": "application/json", "X-Custom": "value" },
"{\"created\": true}"
)
}
Use contracts to specify function behavior - they become automatic validation in HTTP routes:
// In HTTP routes:
// - Failed requires → 400 Bad Request
// - Failed ensures → 500 Internal Server Error
fn create_user(req)
requires len(req.body) > 0
ensures result.status == 201 || result.status == 400
{
let form = parse_form(req)
let name = form["name"]
if len(name) < 2 {
return json(map { "error": "Name too short" }, 400)
}
return json(map { "created": true }, 201)
}
Type Checking: Contract expressions are statically checked by ntnt lint:
requiresandensuresclauses must evaluate toBool- In
ensures,resultis typed to the function's return type old(expr)returns the same type asexpr- Struct invariants are checked with field types in scope
The ? operator unwraps Ok/Some values or early-returns Err/None from the enclosing function:
// ? flattens nested match pyramids into linear code
fn process_request(req) {
let data = parse_json(req)? // Err → early-return Err
let valid = validate(data)? // Err → early-return Err
let result = save_to_db(valid)? // Err → early-return Err
return Ok(json(result))
}
// Also works with Option
fn find_user_email(id) {
let user = find_user(id)? // None → early-return None
let email = user_email(user)? // None → early-return None
return Some(email)
}
Behavior:
Ok(v)?→ evaluates tovErr(e)?→ early-returnsErr(e)from the enclosing functionSome(v)?→ evaluates tovNone?→ early-returnsNonefrom the enclosing function- Non-Result/Option values pass through unchanged (gradual typing)
otherwise unwraps Ok/Some or runs a diverging block for Err/None. Unlike ?, it handles errors at the call site with custom recovery logic:
// Block form — err is automatically bound to the error value
fn create_user(req) {
let data = parse_json(req) otherwise {
return status(400, "Invalid JSON: {err}")
}
let saved = execute(db, "INSERT INTO users (name) VALUES ($1)", [data["name"]]) otherwise {
return status(500, "Database error: {err}")
}
return json(map { "created": true }, 201)
}
// Single-expression form (no braces needed)
fn get_user(req) {
let user = find_user(req.params.id) otherwise return not_found("User not found")
return json(user)
}
// In loops — use continue to skip, break to stop
for line in lines {
let value = parse_line(line) otherwise {
print("Skipping bad line: {err}")
continue
}
process(value)
}
Behavior:
Ok(v)/Some(v)→ bindsvto the variableErr(e)/None→ runs the otherwise block witherrbound toe(orUnitfor None)- The otherwise block must diverge:
return,break,continue, or call a function that doesn't return - Non-Result/Option values bind as-is (gradual typing)
| Pattern | Use When |
|---|---|
? operator |
Propagating errors to the caller (library/internal code) |
otherwise |
Handling errors with specific recovery at the call site |
match |
Complex branching on multiple variants |
unwrap() |
Quick prototyping (panics on error) |
import { connect, query } from "std/db/postgres"
// Using match for explicit handling
let result = connect("postgres://...")
match result {
Ok(db) => {
// Use the connection
let users = query(db, "SELECT * FROM users", [])
match users {
Ok(rows) => print("Found {len(rows)} users"),
Err(e) => print("Query failed: {e}")
}
},
Err(e) => print("Connection failed: {e}")
}
// Using unwrap for quick prototyping (panics on error)
let db = unwrap(connect("postgres://..."))
let users = unwrap(query(db, "SELECT * FROM users", []))
NTNT uses gradual typing — type annotations are optional, and untyped code continues to work as before. When annotations are present, the type checker catches errors at lint time.
// Variable annotations
let name: String = "Alice"
let age: Int = 30
let scores: Array<Float> = [9.5, 8.2, 7.8]
// Function parameter and return types
fn greet(name: String) -> String {
return "Hello, {name}!"
}
// Default parameter values (with or without type annotations)
fn connect(host: String = "localhost", port: Int = 5432) -> String {
return "{host}:{port}"
}
// No annotations required — these work fine
let x = 42
fn add(a, b) { return a + b }
| Type | Description | Example |
|---|---|---|
Int |
Integer | let x: Int = 42 |
Float |
Floating-point | let x: Float = 3.14 |
Bool |
Boolean | let x: Bool = true |
String |
String | let x: String = "hi" |
Unit |
No value | Return type of print() |
Array<T> |
Array of type T | let x: Array<Int> = [1, 2, 3] |
Map<K, V> |
Map with typed keys/values | let m: Map<String, Int> |
Option<T> |
Optional value | Some(42) or None |
Result<T, E> |
Success or error | Ok(value) or Err(msg) |
Request |
HTTP request object | fn handler(req: Request) |
Response |
HTTP response object | fn handler(req: Request) -> Response |
T1 | T2 |
Union type | Int | String |
The type checker tracks types through common operations:
unwrap()—unwrap(Optional<T>)→T,unwrap(Result<T, E>)→T- Collection functions preserve element types —
filter(),sort(),reverse(),slice(),concat(),push()returnArray<T>when givenArray<T> - Element accessors return element type —
first(),last(),pop()onArray<T>returnT flatten()—flatten(Array<Array<T>>)→Array<T>(unwraps one level)- Math functions preserve numeric type —
abs(),min(),max(),clamp()returnIntorFloatbased on input - Map accessors return typed results —
keys(Map<K, V>)→Array<K>,values(Map<K, V>)→Array<V>,get_key(Map<K, V>, key)→V - Map index access —
map["key"]onMap<K, V>returnsV transform()infers callback return —transform(Array<T>, fn(T)->R)→Array<R>when callback is a typed named functionhtml(),json(),text(),redirect()— all returnResponseparse_json()— returnsResult<Map<String, Any>, String>(unwrap gives a map). JSONnullbecomesNone.fetch()— returnsResult<Response, String>(unwrap givesResponse)parse_datetime()— returnsResult<Int, String>parse_csv()— returnsArray<Array<String>>- Match arm narrowing —
Ok(data)onResult<T, E>bindsdataasT;Some(x)onOption<T>bindsxasT; struct patterns bind field types - Cross-file imports —
import { foo } from "./lib/utils"resolves function signatures from the imported.tntfile
The type checker runs during ntnt lint and ntnt validate, and reports:
- Argument type mismatches: passing
IntwhereStringis expected - Wrong argument count: calling
f(a, b)with one argument - Return type mismatches: returning
Stringfrom a function declared-> Int - Let binding mismatches:
let x: Int = "hello"
fn greet(name: String) -> String {
return "Hello, {name}!"
}
greet(42) // Type error: expected String, got Int
- Untyped parameters default to
Any— compatible with everything - Functions without return type annotations skip return checking
- Cross-file types from imported
.tntmodules useAny - No flow-sensitive narrowing (e.g., checking
Nonebefore access)
This means existing untyped code produces zero type errors.
Strict mode warns about untyped function signatures and blocks execution on type errors. Three ways to activate:
- CLI flag:
ntnt lint --strict - Environment variable:
NTNT_STRICT=1 - Project config: Create
ntnt.tomlin project root:
[lint]
strict = true# Lint with strict warnings (untyped params, missing return types)
ntnt lint --strict server.tnt
# Block execution on type errors
NTNT_STRICT=1 ntnt run server.tnt
# Also blocks hot-reload — keeps previous working version running
NTNT_STRICT=1 ntnt run server.tnt| Command | Type Checker | Blocks? |
|---|---|---|
ntnt lint |
Always runs | Reports diagnostics (never blocks) |
ntnt lint --strict |
Runs + warns on untyped signatures | Reports diagnostics (never blocks) |
ntnt validate |
Always runs | Type errors count as validation failures |
ntnt run |
Only with NTNT_STRICT=1 |
Blocks execution if type errors found |
| Hot-reload | Only with NTNT_STRICT=1 |
Blocks reload, keeps previous version |
import { connect, query, execute, close } from "std/db/sqlite"
let db = unwrap(connect("app.db")) // File-based
let db = unwrap(connect(":memory:")) // In-memory
// Create tables
execute(db, "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", [])
// Parameterized queries (? placeholders)
execute(db, "INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", 30])
let users = unwrap(query(db, "SELECT * FROM users WHERE age > ?", [18]))
for user in users {
print("Name: {user[\"name\"]}")
}
close(db)
import { connect, query, execute, close } from "std/db/postgres"
let db = unwrap(connect("postgres://user:pass@localhost/mydb"))
// Parameterized queries ($1, $2 placeholders)
let users = unwrap(query(db, "SELECT * FROM users WHERE active = $1", [true]))
for user in users {
print("Name: {user[\"name\"]}")
}
execute(db, "INSERT INTO users (name, age) VALUES ($1, $2)", [name, int(age_str)])
close(db)
Type conversion for database:
let form = parse_form(req)
let age = int(form["age"]) // Convert string to int!
let price = float(form["price"])
// WRONG - String to integer column causes "db error"
execute(db, "INSERT INTO users (age) VALUES ($1)", [form["age"]])
NULL handling: SQL NULL values are returned as None (not Unit) in query results. Use None when inserting NULL values:
// Reading NULL from database
let user = unwrap(query_one(db, "SELECT * FROM users WHERE id = ?", [1]))
match user["middle_name"] {
None => print("No middle name"),
Some(name) => print(name),
name => print(name) // also works with gradual typing
}
// Inserting NULL
execute(db, "INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", None])
// query_one returns Ok(None) when no row matches
let result = query_one(db, "SELECT * FROM users WHERE id = ?", [999])
match result {
Ok(Some(row)) => print("Found: {row}"),
Ok(None) => print("No row found"),
Err(e) => print("Query error: {e}")
}
Triple-quoted with {{expr}} interpolation (CSS-safe):
let page = """
<style>h1 { color: blue; }</style>
<h1>Hello, {{name}}!</h1>
{{#for item in items}}
<p>{{item.name}}: ${{item.price}}</p>
{{/for}}
{{#if logged_in}}
<a href="/logout">Logout</a>
{{#else}}
<a href="/login">Login</a>
{{/if}}
"""
Available filters: uppercase, lowercase, capitalize, trim, truncate(n), escape, json, url_encode
Loop metadata: @index, @length, @first, @last, @even, @odd
let page = template("views/home.html", map {
"title": "Welcome",
"items": items
})
return html(page)
Template paths are relative to the .tnt file.
Important: External template files (.html) are rendered internally by wrapping their content in """...""" triple quotes. This means template HTML must not contain literal """ anywhere in the content — the lexer will interpret it as the closing delimiter and truncate the output. If you need to display triple quotes (e.g., in code examples showing Elixir's @doc """), use HTML entities """ instead. They render identically in the browser.
routes("routes") // Auto-discover from directory
listen(8080)
routes/
├── index.tnt # GET /
├── api/
│ ├── users.tnt # GET/POST /api/users
│ └── [id].tnt # GET /api/:id (dynamic segment)
Route files export get, post, etc. functions.
// Global middleware applied to all routes
use_middleware(fn(req) {
print("Request: {req.method} {req.path}")
// Return nothing to continue, return response to short-circuit
})
// Middleware for authentication
use_middleware(fn(req) {
if starts_with(req.path, "/api/") {
let token = req.headers["authorization"]
if !is_valid_token(token) {
return json(map { "error": "Unauthorized" }, 401)
}
}
})
NTNT doesn't have a debugger. Use these strategies:
- Print statements:
print("Debug: {variable}") - Contracts: Add
requires/ensuresto catch invalid states - Lint first:
ntnt lintcatches most syntax errors - Intent Studio: Shows live test results as you code
| Function | Description |
|---|---|
print(x) |
Output to stdout |
len(x) |
Length of string/array |
str(x) |
Convert to string |
int(x) |
Convert to integer |
float(x) |
Convert to float |
type(x) |
Get type name |
push(arr, item) |
Add to array |
filter(arr, fn) |
Filter array with predicate |
transform(arr, fn) |
Transform array elements |
assert(cond) |
Assert condition |
abs(n), min(a,b), max(a,b) |
Math functions |
round(n), round(n, decimals), floor(n) |
Rounding |
get/post/put/patch/delete(pattern, handler) |
HTTP routes |
listen(port) |
Start server |
serve_static(prefix, dir) |
Static files |
routes(dir) |
File-based routing |
template(path, vars) |
Load template |
use_middleware(fn) |
Add middleware |
on_shutdown(fn) |
Cleanup handler |
import { split, join, trim, replace, contains, capture_pattern, capture_all_pattern, capture_named_pattern } from "std/string"
import { json, html, text, redirect, status, not_found, error, response, parse_form, parse_json } from "std/http/server"
import { connect, query, query_one, execute, begin, commit, rollback, close } from "std/db/postgres"
import { connect, query, query_one, execute, begin, commit, rollback, close } from "std/db/sqlite"
import { fetch, download } from "std/http"
import { read_file, write_file, exists } from "std/fs"
import { parse_json, stringify } from "std/json"
import { get_env, load_env } from "std/env"
import { now, format } from "std/time"
import { sha256, uuid } from "std/crypto"
import { first, last, keys, values, entries, has_key, get_key, get_index } from "std/collections"
ntnt run <file> # Run a .tnt file
ntnt lint <file> # Check for errors
ntnt lint --strict <file> # Check with strict type warnings
ntnt intent check <file> # Verify code matches intent
ntnt intent studio <intent> # Visual studio with live tests
ntnt intent coverage <file> # Show feature coverage
ntnt intent init <intent> # Generate scaffolding
ntnt inspect <file> # Project structure as JSON
ntnt validate <file> # Validate with JSON output
ntnt docs [query] # Search stdlib documentation
ntnt docs --generate # Regenerate STDLIB_REFERENCE.md
ntnt docs --validate # Check documentation coverage
ntnt test <file> --get / # Quick HTTP endpoint testingNTNT provides context-rich error messages with error codes, source snippets, and suggestions:
- Error codes (E001-E012): Every error type has a unique code (e.g.,
E001for undefined variables,E003for arity mismatches). These are color-coded red in terminal output. - "Did you mean?" suggestions: Typos in variable or function names trigger Levenshtein-distance-based suggestions (shown in green). For example, writing
usrwhenuseris defined will suggest the correct name. - Source code snippets: Parser errors display 3 lines of context around the error location with line and column numbers (line numbers shown in blue).
- Function names in arity errors: When calling a function with the wrong number of arguments, the error message includes the function name for easier debugging.
Example error output:
Error[E001]: Undefined variable `usr`
--> server.tnt:45:12
|
45 | return json(usr)
|
help: did you mean `user`?
| Error | Cause | Fix |
|---|---|---|
unexpected token '{' |
Using {} for map literal |
Add map keyword: map { "key": "value" } |
unexpected token '$' |
Using ${expr} interpolation |
Use {expr} without the $ |
expected identifier |
Inline lambda in route | Use named function: fn handler(req) { ... } |
unexpected token '.' |
Method-style call on stdlib function | Use function style: len(s) not s.len(). Dot notation is for reading properties, not calling stdlib functions. |
Required parameter 'x' cannot follow a parameter with a default value |
Non-default param after default | Move all required params before defaulted ones: fn f(a, b = 1) not fn f(a = 1, b) |
| Error | Cause | Fix |
|---|---|---|
requires clause failed |
Precondition not met | Check input values meet contract requirements |
ensures clause failed |
Postcondition not met | Fix function to return correct values |
key not found |
Missing map key | Use has_key() to check, or get_key() for Option |
index out of bounds |
Array index invalid | Check len() before accessing |
db error |
Type mismatch in query | Convert types: int(form["age"]) for integers |
When contracts fail in HTTP handlers:
requiresfails → Returns400 Bad Requestwith contract messageensuresfails → Returns500 Internal Server Errorwith contract message
Example:
fn create_user(req)
requires len(req.body) > 0 // 400 if body is empty
{
// ...
}
| Issue | Meaning | Fix |
|---|---|---|
unresolved term |
Glossary term not defined | Add term to ## Glossary section |
feature not implemented |
Missing @implements |
Add // @implements: feature.id to function |
assertion failed |
Test didn't pass | Fix implementation to match expected behavior |
status mismatch |
Wrong HTTP status | Check route returns correct status code |
When using ntnt lint or NTNT_STRICT=1, you may see type diagnostics:
| Error | Cause | Fix |
|---|---|---|
expected String, got Int |
Wrong argument type | Convert with str(x) or fix the call |
expected 2 args, got 1 |
Wrong argument count | Check function signature |
returns Int, expected String |
Return type mismatch | Fix return value or annotation |
expected Int, got String |
Let binding mismatch | Fix the assigned value or annotation |
- Always lint first:
ntnt lint file.tntcatches 90% of issues (including type errors) - Use print statements:
print("Debug: {variable}") - Check types:
print("Type: {type(variable)}") - Add type annotations: Helps the type checker catch more bugs
- Add contracts: They catch bugs at precise locations
- Use Intent Studio: Live feedback as you code
See STDLIB_REFERENCE.md for complete function documentation.