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
typeoverinterfaceunless declaration merging is needed. Interfaces can accidentally merge across files;typealiases are closed.unknownoverany. Ifanyis unavoidable, add a// eslint-disable-next-linewith justification.- Use
satisfiesto validate object shapes without widening. - Import types with
import type(enforced bytypescript/consistent-type-imports). - Derive types from values with
as const, not the other way around. - Discriminated unions with exhaustive
switchfor 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:
- Accept
unknownor raw platform type. - Validate with Zod, Valibot, a typed parser, or a local predicate.
- Return a named domain type.
- Keep the parsed type narrow. Do not widen it back to
Record<string, unknown>. - 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
| undefinedon optional members. WritepeakValue?: number, notpeakValue?: number | undefined— the explicit form tripstypescript-eslint(no-restricted-types). The?already encodes optionality. - A value that genuinely can be
undefinedgets 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. unknownis a boundary type, not a field type. Use it for caught errors and unparsed external input, then narrow immediately. A struct field typedunknownmeans 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/functionalfor domain logic with expected failures.Zodfor validating external input at API boundaries.catch (error: unknown), narrow witherror 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
anywithout a line-level justification. - No object fields typed
unknownafter 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/
tsgoguidance. 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