Skip to content
My Guidelines
Guides
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: strict compiler settings, branded types for identifiers, discriminated unions for errors, Result over throw for expected outcomes, and type over interface except for ports. any is 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/ and application/ 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-eslint recommended-type-checked — static analysis: complexity, exhaustiveness, floating promises, unsafe any.
  • 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
}
}
FlagWhat it prevents
noUncheckedIndexedAccessarray[0] typed as defined on an empty array — the most common source of runtime undefined
exactOptionalPropertyTypes{ note?: string } silently accepting { note: undefined }
noFallthroughCasesInSwitchAccidental fallthrough in domain-error handling
verbatimModuleSyntaxType 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 type for type-only imports.
  • One export per file for classes, components, use cases and ports. Utility modules may export several related functions.

3. Naming

KindConventionExample
Files — types/componentsPascalCaseExpense.ts, ExpenseListScreen.tsx
Files — functions/modulescamelCasecreateExpense.ts, formatMoney.ts
Types / interfacesPascalCase nounsExpense, ExpenseRepository
FunctionslowerCamelCase verbscreateExpense, findById
ConstantsUPPER_SNAKE_CASEMAX_RETRIES, DEFAULT_TIMEOUT_MS
Booleansis / has / can / should prefixisActive, hasPermission
React hooksuse prefixuseCreateExpense
Zustand storesuse…StoreuseFilterStore
Testsshould_{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

UseFor
interfacePorts — the contract an adapter implements. Signals “this will have implementations”.
typeEverything else — domain models, unions, function types, props, commands
// Port — interface
export interface ExpenseRepository {
save(expense: Expense): Promise<Result<Expense, PersistenceError>>
}
// Domain model — type
export 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.

  • const always. let only when reassignment is genuinely required. var never.
  • Domain models: wrap in Readonly<> or mark every field readonly. They return new instances; they never mutate.
  • Collections crossing a boundary: readonly T[], not T[].
  • Update by spreading, never by mutating:
// ✅
const updated: Expense = { ...expense, note }
// ❌ compiles only because the type wasn't readonly
expense.note = note

as 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 symbol
export 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 swapped
transfer(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>

domain/shared/Result.ts
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 async without handling rejection inside it.
  • catch (e: unknown) — e is not an Error until 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.
  • null only where an external contract demands it (a JSON API, a SQL column). Convert it to undefined at the adapter.
  • Never T | null | undefined. Two ways to say “nothing” doubles every check.
  • Never use null to signal an error — that is what Result is for.

9. Banned constructs

❌Why✅
anyDisables the compiler silentlyunknown + narrowing, or a real type
as castA lie the compiler must acceptZod parse, or a type guard
! non-null assertionPostpones the crashExplicit check, or Result
@ts-ignoreHides the error and the reason@ts-expect-error with justification
enumEmits runtime code, awkward interopas const object + keyof typeof, or a string literal union
namespacePre-modules legacyES modules
Default exportsRename-unsafe, poor autocompleteNamed exports (Expo Router route files are the exception — the framework requires them)
Function, object, {}Effectively anyA precise signature
Promise<void> ignoredSilent unhandled rejectionawait it or handle it
// ✅ enum replacement
export const ExpenseStatus = { Draft: 'draft', Confirmed: 'confirmed' } as const
export 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 object
export 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-hooks with exhaustive-deps set to error, not warn.
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.
  • @deprecated with a migration path before removing any exported API.

13. Anti-patterns

❌✅
id: stringid: ExpenseId (branded)
throw new Error('not found')Result<T, NotFound>
catch (e) { console.log(e) }Log with context or rethrow with context
data as ExpenseexpenseSchema.safeParse(data)
interface IExpenseRepositoryinterface ExpenseRepository
enum Status { … }as const object + union type
let total = 0; for (…) total += …reduce, or extract a named function
Amount as number of major unitsInteger minor units, or a decimal type
useEffect running business logicUse case called from an event handler
Exported function with inferred return typeExplicit return type

14. Pre-merge checklist

  • tsc --noEmit passes with the strict flags of §1
  • No any, no as, no !, no @ts-ignore
  • All identifiers are branded types
  • Domain errors are discriminated unions; switch ends in assertNever
  • 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