In this guide (19 items)
TypeScript Code Style
TypeScript Code Style
MANDATORY FOR ALL AI AGENTS: When generating TypeScript you MUST follow every convention below. Pay special attention to:
strictcompiler settings, branded types for identifiers, discriminated unions for errors,Resultoverthrowfor expected outcomes, andtypeoverinterfaceexcept for ports.anyis banned. Domain and application layers MUST remain framework-free.
Companion command: /mobile-init-project — generates the tsconfig.json strict flags of §1 and the ESLint setup.
This document is the TypeScript counterpart of kotlin-code-style.md and java-code-style.md. Where not specified, follow the TypeScript Handbook and the ESLint recommended-type-checked ruleset.
Targets
- Make illegal states unrepresentable. Prefer a type that cannot express the bad case over a runtime check that catches it.
- Domain purity.
domain/andapplication/compile without React, React Native or any I/O. - Deterministic formatting, enforced by tooling, never by review comments.
Tooling split — same philosophy as Spotless/Detekt in gradle.md:
- Prettier or Biome — formatting only. One of them, never both.
- ESLint with
@typescript-eslintrecommended-type-checked— static analysis: complexity, exhaustiveness, floating promises, unsafeany. import/no-restricted-paths— architectural boundaries. This is the ArchUnit equivalent; see mobile-architecture.md §9.
1. Compiler configuration
Non-negotiable:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": true }}| Flag | What it prevents |
|---|---|
noUncheckedIndexedAccess | array[0] typed as defined on an empty array — the most common source of runtime undefined |
exactOptionalPropertyTypes | { note?: string } silently accepting { note: undefined } |
noFallthroughCasesInSwitch | Accidental fallthrough in domain-error handling |
verbatimModuleSyntax | Type imports surviving into the bundle |
⚠️ Do not set baseUrl. It is deprecated in TypeScript 6 and stops working in 7. Path aliases work without it as long as each mapping is relative to the tsconfig.json ("./src/domain/*").
Weakening any of these requires an ADR. // @ts-ignore is banned; // @ts-expect-error is allowed only with a comment explaining why and a link to the upstream issue.
2. Formatting
- Max line length: 120 (matches coding-style rules).
- Indentation: 2 spaces. TypeScript convention, not the JVM’s 4.
- Semicolons: whatever the formatter is configured to do. Never argue about it.
- Imports: no wildcards, sorted,
import typefor type-only imports. - One export per file for classes, components, use cases and ports. Utility modules may export several related functions.
3. Naming
| Kind | Convention | Example |
|---|---|---|
| Files — types/components | PascalCase | Expense.ts, ExpenseListScreen.tsx |
| Files — functions/modules | camelCase | createExpense.ts, formatMoney.ts |
| Types / interfaces | PascalCase nouns | Expense, ExpenseRepository |
| Functions | lowerCamelCase verbs | createExpense, findById |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, DEFAULT_TIMEOUT_MS |
| Booleans | is / has / can / should prefix | isActive, hasPermission |
| React hooks | use prefix | useCreateExpense |
| Zustand stores | use…Store | useFilterStore |
| Tests | should_{expected}_when_{condition} | see mobile-testing.md |
Never prefix interfaces with I. IExpenseRepository is a C# habit that carries no information in TypeScript.
Never name a port after its technology. ExpenseRepository is a port; MmkvExpenseRepository is an adapter.
4. type vs interface
| Use | For |
|---|---|
interface | Ports — the contract an adapter implements. Signals “this will have implementations”. |
type | Everything else — domain models, unions, function types, props, commands |
// Port — interfaceexport interface ExpenseRepository { save(expense: Expense): Promise<Result<Expense, PersistenceError>>}
// Domain model — typeexport type Expense = Readonly<{ id: ExpenseId amount: Money occurredAt: Instant}>Discriminated unions require type. Declaration merging (the only real interface superpower) is a liability in application code.
5. Immutability
Default to immutable, same as val in Kotlin and final in Java.
constalways.letonly when reassignment is genuinely required.varnever.- Domain models: wrap in
Readonly<>or mark every fieldreadonly. They return new instances; they never mutate. - Collections crossing a boundary:
readonly T[], notT[]. - Update by spreading, never by mutating:
// ✅const updated: Expense = { ...expense, note }
// ❌ compiles only because the type wasn't readonlyexpense.note = noteas const for literal tuples and config objects.
6. Branded types
Primitive obsession is the most expensive habit in a TypeScript codebase. string for an id means every id is assignable to every other id.
declare const brand: unique symbolexport type Brand<T, B extends string> = T & { readonly [brand]: B }
export type ExpenseId = Brand<string, 'ExpenseId'>export type UserId = Brand<string, 'UserId'>// ✅ compile error — arguments swappedtransfer(expenseId, userId)Brand every identifier, and every primitive carrying units or constraints: Email, Iso4217Currency, Minutes, AmountMinor. Construct them only through a validating factory, never with a bare cast.
7. Errors
Exceptions are for bugs. Expected outcomes are values.
A user submitting an amount that is too large is not exceptional — it is a normal path the caller must handle. A throw in an async React tree is invisible until it reaches a user.
Domain errors: discriminated unions
export type ExpenseError = | { kind: 'AmountTooLarge'; max: number } | { kind: 'CategoryUnknown'; category: string } | { kind: 'DateInFuture'; date: Instant }The kind discriminant makes switch exhaustiveness compiler-checked:
function message(error: ExpenseError): string { switch (error.kind) { case 'AmountTooLarge': return t('error.amountTooLarge', { max: error.max }) case 'CategoryUnknown': return t('error.categoryUnknown') case 'DateInFuture': return t('error.dateInFuture') default: return assertNever(error) }}
export function assertNever(value: never): never { throw new Error(`Unhandled case: ${JSON.stringify(value)}`)}Adding a variant to ExpenseError now breaks the build at every incomplete switch. That is the point.
Result<T, E>
export type Result<T, E> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E }
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value })export const err = <E>(error: E): Result<never, E> => ({ ok: false, error })Use cases and ports return Result. The UI unwraps it once, at the edge.
Adopting a library (neverthrow, effect) instead is fine — but it is an ADR, and it must then be used everywhere.
When throw is correct
- Programmer errors:
useContainer()called outside its provider. - Unrecoverable invariant violations that indicate a bug.
- Never for validation, network failures, or “not found”.
Async
- Never leave a promise floating. Enable
@typescript-eslint/no-floating-promises. - Never mark a React event handler
asyncwithout handling rejection inside it. catch (e: unknown)—eis not anErroruntil you prove it.- Never swallow: log with context or rethrow with context. Same rule as the JVM guides.
8. null vs undefined
Pick one and be consistent. Default: undefined — it is what TypeScript’s optionality and JS defaults produce naturally.
undefined= absent.nullonly where an external contract demands it (a JSON API, a SQL column). Convert it toundefinedat the adapter.- Never
T | null | undefined. Two ways to say “nothing” doubles every check. - Never use
nullto signal an error — that is whatResultis for.
9. Banned constructs
| ❌ | Why | ✅ |
|---|---|---|
any | Disables the compiler silently | unknown + narrowing, or a real type |
as cast | A lie the compiler must accept | Zod parse, or a type guard |
! non-null assertion | Postpones the crash | Explicit check, or Result |
@ts-ignore | Hides the error and the reason | @ts-expect-error with justification |
enum | Emits runtime code, awkward interop | as const object + keyof typeof, or a string literal union |
namespace | Pre-modules legacy | ES modules |
| Default exports | Rename-unsafe, poor autocomplete | Named exports (Expo Router route files are the exception — the framework requires them) |
Function, object, {} | Effectively any | A precise signature |
Promise<void> ignored | Silent unhandled rejection | await it or handle it |
// ✅ enum replacementexport const ExpenseStatus = { Draft: 'draft', Confirmed: 'confirmed' } as constexport type ExpenseStatus = (typeof ExpenseStatus)[keyof typeof ExpenseStatus]10. Functions
From coding-style rules, unchanged for TypeScript:
- Max 8–10 lines. Extract beyond that.
- Max 3 parameters. More becomes a command object.
- One thing per function. The name states it.
- Explicit return types on every exported function. Inference is fine internally; at a module boundary it turns a refactor into a silent API change.
// ✅ command objectexport type CreateExpenseCommand = Readonly<{ amountMinor: number currency: Currency category: Category note?: string}>
export function createExpense(deps: Deps) { return (command: CreateExpenseCommand): Promise<Result<Expense, ExpenseError>> => { /* ... */ }}Prefer pure functions over classes in domain/ and application/. Classes are justified when identity and encapsulated mutable state are genuinely required — rare in an immutable domain.
11. React and component conventions
Applies to ui/ only.
- Function components. No class components.
- Props typed with a
type, never inline in the signature for anything non-trivial. - Destructure props in the signature.
- No default export except Expo Router route files.
- One component per file; small private subcomponents in the same file are fine.
- No business logic in components. It belongs in a use case.
- Hook rules are compiler-enforced:
eslint-plugin-react-hookswithexhaustive-depsset toerror, notwarn.
type ExpenseRowProps = Readonly<{ expense: Expense onPress: (id: ExpenseId) => void}>
export function ExpenseRow({ expense, onPress }: ExpenseRowProps) { /* ... */ }Event handler props are named onX; the handler implementations are named handleX.
12. Comments
Same rule as every other guide in this knowledge base: comments explain WHY, not WHAT.
- Delete commented-out code. Git has history.
- TSDoc on exported ports and non-obvious domain rules — especially where a rule comes from a regulation or a business decision that the code alone cannot justify.
@deprecatedwith a migration path before removing any exported API.
13. Anti-patterns
| ❌ | ✅ |
|---|---|
id: string | id: ExpenseId (branded) |
throw new Error('not found') | Result<T, NotFound> |
catch (e) { console.log(e) } | Log with context or rethrow with context |
data as Expense | expenseSchema.safeParse(data) |
interface IExpenseRepository | interface ExpenseRepository |
enum Status { … } | as const object + union type |
let total = 0; for (…) total += … | reduce, or extract a named function |
Amount as number of major units | Integer minor units, or a decimal type |
useEffect running business logic | Use case called from an event handler |
| Exported function with inferred return type | Explicit return type |
14. Pre-merge checklist
-
tsc --noEmitpasses with the strict flags of §1 - No
any, noas, no!, no@ts-ignore - All identifiers are branded types
- Domain errors are discriminated unions;
switchends inassertNever - Use cases and ports return
Result, never throw for expected outcomes - Exported functions have explicit return types
- Domain models are
readonly - No floating promises
- ESLint including boundary rules passes