TypeScript Style

Conventions for TypeScript code in the Dedalus monorepo.

General Principles

Prefer functional style. Use classes only when the domain demands it (SDK clients). Prefer const over let; never use var. Use === over ==. Prefer template literals over string concatenation. Named exports over default exports. If a function name contains "and", split it.

TypeScript exists to make invalid states difficult to express and boundary mistakes obvious. Treat types as executable documentation for future agents. A loose type that gets past review today becomes an instruction for the next agent to keep guessing.

Types

  • type over interface unless declaration merging is needed. Interfaces can accidentally merge across files; type aliases are closed.
  • unknown over any. If any is unavoidable, add a // eslint-disable-next-line with justification.
  • Use satisfies to validate object shapes without widening.
  • Import types with import type (enforced by typescript/consistent-type-imports).
  • Derive types from values with as const, not the other way around.
  • Discriminated unions with exhaustive switch for domain variants.
  • Branded types (string & { readonly __brand: "X" }) for nominal safety.

Boundary protocol

External data enters as unknown, gets parsed immediately, and becomes a precise domain type before it crosses into business logic. This applies to API request bodies, webhook payloads, localStorage, environment variables, third-party SDK responses, URL params, and JSON imports.

Boundary shape:

  1. Accept unknown or raw platform type.
  2. Validate with Zod, Valibot, a typed parser, or a local predicate.
  3. Return a named domain type.
  4. Keep the parsed type narrow. Do not widen it back to Record<string, unknown>.
  5. Surface parse failures as typed errors that name the boundary.

The parser is part of the contract. If two callers parse the same payload, extract the parser. If a parser returns any, the parser has failed its job.

Optionality

undefined and unknown as data types are almost always too loose — it is usually possible to type the data specifically. Reviewers treat loose optionality as a defect, not a convenience. Source: Windsor Nguyen, PR #2297

  • No explicit | undefined on optional members. Write peakValue?: number, not peakValue?: number | undefined — the explicit form trips typescript-eslint(no-restricted-types). The ? already encodes optionality.
  • A value that genuinely can be undefined gets a named union, hoisted to the top of the file: type PeakValue = number | undefined. This is intentionally annoying so the type is a conscious decision, not an accident.
  • unknown is a boundary type, not a field type. Use it for caught errors and unparsed external input, then narrow immediately. A struct field typed unknown means the model is incomplete.
// bad: loose and lint-tripping
type ChartProps = { peakValue?: number | undefined; valueFormatter?: ((v: number) => string) | undefined };

// good: optional via `?`, genuine union named once
type ChartProps = { peakValue?: number; valueFormatter?: (v: number) => string };

Error Handling

  • Result<T, E> from @/lib/utils/functional for domain logic with expected failures.
  • Zod for validating external input at API boundaries.
  • catch (error: unknown), narrow with error instanceof Error.
  • Early returns over deeply nested conditionals.
  • No ternaries for control flow. Ternaries are for value selection only.

Errors should tell the operator what invariant failed, not merely that something failed. Prefer small named error types or discriminated error unions for expected failures. Throwing new Error("failed") in a production path is an unfinished interface.

Exhaustiveness and invariants

Use never checks when switching over a discriminated union:

function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

Put invariants near the type they protect. If a value is valid only after normalization, create a normalized type or constructor. Do not rely on a comment in the call site to preserve the invariant.

Agent-written TypeScript checklist

Before marking a TypeScript edit done:

  • Public functions have named input and output types.
  • External input is parsed at the boundary.
  • No new any without a line-level justification.
  • No object fields typed unknown after parsing.
  • Optional fields use ?, not ?: T | undefined.
  • Domain variants are represented as discriminated unions, not stringly typed conditionals.
  • Expected failures return typed results or typed errors.
  • Validation commands ran through the repo's fast path.

TypeScript Contract

Concern Default Banned pattern Proof
External input Accept unknown, parse once, return a named type Raw JSON or SDK payloads inside domain logic Parser test or boundary test
Optionality ? for optional fields, named union for real undefined `field?: T undefined` and loose nullable drift
Variants Discriminated unions with exhaustive never checks Stringly typed branches across files Exhaustive switch test or compiler error
Errors Typed results or named domain errors new Error("failed") in expected paths Caller handles each error variant
Imports import type for types, direct source imports Runtime type imports or barrel traps Bundle/lint/typecheck

Console Warnings

Label console.warn and console.error calls with their origin for traceability: [FileName::functionName::L42].

Linting

Use Ultracite (bun x ultracite@latest init) for oxlint configuration instead of hand-rolling ESLint configs. Ultracite provides zero-config presets for oxlint with framework-specific rules (React, Next.js). Custom ESLint rules layer on top for repo-specific architectural policy only (see Lint-Enforced Agent Guardrails).

Use tsgo (TS 7, native Go implementation) for type checking. Do not use tsc. tsgo is ~10x faster and produces identical type errors. Combined with oxlint, full-repo validation runs in ~250-500ms. See Ultracite & Fast Validation Recipe for the complete recipe.

Default validation order:

pnpm lint
pnpm typecheck
pnpm test

Use the local repo's actual script names when they differ. The ordering stays the same: syntax/style, types, then behavior.

Proof

TypeScript proof is not just tsc or tsgo passing. For boundary or domain changes, add a parser/unit test that would fail if a malformed payload, missing optional field, or unhandled variant slips through. The compiler proves shape; tests prove policy.

Formatting

Formatting is handled exclusively by oxfmt. No Prettier configuration. Run pnpm format to format, pnpm format --check to verify.

Imports

Group: builtins/external first, then internal (@/, ~/), then relative. Alphabetize within groups. Use import type for type-only imports.

Style Rule Contract

This page follows Style Rule Contract: name the default pattern, banned pattern, reason, exception, proof, and codification path clearly enough that the next agent can apply the rule without asking Kevin again. If a local repo has a stricter rule, follow the local rule and update the wiki when the decision becomes durable.


Timeline

  • 2026-07-12 | Added TypeScript 7 general availability as ecosystem context for the existing native-port/tsgo guidance. Use current project compatibility and compiler docs before migration; the announcement is not a blanket upgrade instruction. Source: X Bookmark Ingest 2026-07-12; X/@ahejlsberg, 2026-07-08
  • 2026-07-01 | Aligned this page with Style Rule Contract so style guidance names default behavior, banned patterns, exceptions, proof, and codification expectations. Source: User request, 2026-07-01
  • 2026-07-01 | Added a TypeScript contract and proof rule for boundary parsing, optionality, variants, errors, imports, and policy tests beyond typecheck. Source: User request, 2026-07-01