> For the complete documentation index, see [llms.txt](https://aaron-mota.gitbook.io/aarons-style-guide/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://aaron-mota.gitbook.io/aarons-style-guide/frontend/basic-guidelines/typescript.md).

# TypeScript

Use "good" TypeScript practices:

* **assign a type** when declaring variables (if type is not already correctly inferred)
  * ❌ `const doc = {...} // no type; can have any fields/values`
  * ✅ `const doc: TDocUser = {...} // type = TDocUser ({ id: string, ... })`

* **narrow-scoped types** when possible [\[1\]](https://www.allthingstypescript.dev/p/always-prefer-type-with-a-narrower)
  * ❌ `type Side = string`
    * allows `"left"`, `"right"`, `"askdjf"`, `"jdklfjal"`, ...
  * ✅ `type Side = "left" | "right"`
    * allows `"left"` or `"right"` only

* **avoid `any`**; allow **`unknown` only at boundaries** (with narrowing) [\[1\]](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
  * ❌ `const item: any = {...} // disables type checking entirely`
  * ❌ `const item: unknown = {...} // no boundary -- just type it properly`
  * ✅ `try { ... } catch (err: unknown) { /* narrow before use */ }`
  * ✅ `const data: unknown = JSON.parse(raw); const user = userSchema.parse(data);`

<details>

<summary><strong>Notes (<code>unknown</code> at boundaries)</strong></summary>

`unknown` is the type-safe counterpart to any: it accepts any value, but the compiler forces you to narrow it before use. Acceptable boundary cases:

* catch (err: unknown) blocks
* JSON.parse results
* external/network input prior to schema validation
* runtime parsers (validate then narrow with Zod, a type predicate, typeof/instanceof guard, etc.)

</details>

* minimal **type assertions** (`as`) [\[1\]](https://www.allthingstypescript.dev/p/avoid-using-type-assertions-in-typescript)[\[2\]](https://www.reddit.com/r/typescript/comments/wd3f7j/should_i_avoid_casting_types/)
  * ❌ `const item = {...} as TypeX`
  * ✅ assign a type instead: `const item: TypeX = {...}`
  * ✅ for literal expressions, prefer `satisfies` (see below)

* **avoid non-null assertions** (`!`)
  * ❌ `user!.name`
  * ✅ narrow first: `if (user) user.name` (or use optional chaining: `user?.name`)

* **avoid loose top-types**: `Function`, `Object`, `{}`
  * ❌ `function callIt(fn: Function)`
  * ✅ `function callIt(fn: () => void)`
  * ❌ `function track(payload: Object)`
  * ✅ `function track(payload: Record<string, unknown>)` (or a specific shape)

* prefer **`interface`** for object shapes; prefer **`type`** for unions, primitives, intersections, mapped/conditional types, and utility-type compositions [\[1\]](https://www.typescriptlang.org/docs/handbook/2/objects.html)

<details>

<summary><strong>Example (<code>interface</code> vs <code>type</code>)</strong></summary>

```typescript
// ✅ interface -- object shape (props, models, public APIs)
interface User {
  id: string;
  name: string;
}

interface Props {
  user: User;
  onSelect: (id: string) => void;
}

// ✅ type -- union
type Side = "left" | "right";

// ✅ type -- utility composition
type PartialUser = Partial<User>;
type UserName = User["name"];
```

</details>

* type/interface **naming**: PascalCase, no required prefix
  * exception: **`T` prefix for Zod-derived database collection schema types**

<details>

<summary><strong>Notes (<code>T</code> prefix for Zod DB schemas)</strong></summary>

When a type is derived from a Zod schema that models a MongoDB collection (or DB table), prefix it with `T`:

* single import in a file: `TDoc`
* multiple imports in a file: append the collection name -- `TDocUser`, `TDocOrg`, etc.

This keeps DB-backed shapes visually distinct from in-memory types, props, etc.

</details>

<details>

<summary><strong>Example (<code>T</code> prefix for Zod DB schemas)</strong></summary>

```typescript
import { z } from "zod";

export const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
});

export type TDocUser = z.infer<typeof userSchema>;
```

</details>

* **avoid `enum`**; use **string unions** or **`as const` objects** instead
  * string unions: simple sets of values
  * `as const` objects: when you also need a runtime mapping

<details>

<summary><strong>Notes (avoid <code>enum</code>)</strong></summary>

* numeric enums leak both directions (you can pass `7` where `Color` is expected)
* `const enum` breaks under `isolatedModules` (used by Next.js, Vite, Babel)
* string unions and `as const` objects are simpler, tree-shakeable, and play well with Zod (`z.enum([...])`)

</details>

<details>

<summary><strong>Example (avoid <code>enum</code>)</strong></summary>

```typescript
// ❌ enum
enum Side { Left = "left", Right = "right" }

// ✅ string union
type Side = "left" | "right";

// ✅ as const object (when a runtime mapping is needed)
export const SIDE = {
  LEFT: "left",
  RIGHT: "right",
} as const;

export type Side = (typeof SIDE)[keyof typeof SIDE]; // "left" | "right"
```

</details>

* model variant data with **discriminated unions** [\[1\]](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions)

<details>

<summary><strong>Example (discriminated union)</strong></summary>

```typescript
// ✅ discriminated by `status`
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

function render(state: RequestState) {
  if (state.status === "success") return <UserCard user={state.data} />;
  if (state.status === "error") return <ErrorBanner message={state.error.message} />;
  // state is now { status: "idle" } | { status: "loading" }
}
```

</details>

* consider **branded types** for IDs and other primitives that shouldn't be interchangeable

<details>

<summary><strong>Example (branded types)</strong></summary>

```typescript
type UserId = string & { readonly __brand: "UserId" };
type OrgId = string & { readonly __brand: "OrgId" };

// single, contained `as` at construction (acceptable cost of branding)
const asUserId = (id: string) => id as UserId;
const asOrgId = (id: string) => id as OrgId;

function getUser(id: UserId) { /* ... */ }

const orgId = asOrgId("org_123");
getUser(orgId); // ❌ Type error -- OrgId is not assignable to UserId
```

</details>

* **generic type parameters**: use descriptive names (no required prefix)
  * ❌ `function pickRandom<T>(items: T[]): T`
  * ✅ `function pickRandom<Item>(items: Item[]): Item`
  * ✅ `function mapValues<Key extends string, Value>(obj: Record<Key, Value>, ...)`

* **function return types**: rely on inference; require explicit return types on **exported / public-API** functions

<details>

<summary><strong>Notes (return types)</strong></summary>

* inferred returns keep internal code uncluttered and refactor-friendly
* explicit returns at module boundaries (exports, route handlers, tRPC procedures, hooks) lock the public contract -- changing the implementation can't silently change the public type

</details>

<details>

<summary><strong>Example (return types)</strong></summary>

```typescript
// ✅ internal helper -- inferred
function fullName(user: User) {
  return `${user.firstName} ${user.lastName}`;
}

// ✅ exported function -- explicit
export function formatUser(user: User): FormattedUser {
  return { ... };
}

// ✅ exported hook -- explicit
export function useUser(id: string): { user: User | undefined; isLoading: boolean } {
  ...
}
```

</details>

* use **type predicates** (`x is Foo`) for custom narrowing functions [\[1\]](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)

<details>

<summary><strong>Example (type predicate)</strong></summary>

```typescript
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "email" in value
  );
}

const data: unknown = await res.json();
if (isUser(data)) {
  data.email; // ✅ narrowed to User
}
```

</details>

* prefer **`undefined`** over `null` for "absent" values
  * matches optional props (`x?: string` is `string | undefined`)
  * matches default JS behavior (missing properties, unset variables)
  * dropped by `JSON.stringify`
  * use `null` only for **intentionally cleared** values, or for **data-layer compatibility** (MongoDB, SQL `NULL`, etc.)

* **array syntax**: prefer `T[]`; use `Array<T>` only for complex inline types
  * ✅ `const ids: string[]`
  * ✅ `const handlers: Array<(e: MouseEvent) => void>`

* use **`readonly`** for arrays/properties that shouldn't mutate
  * ✅ `function sum(nums: readonly number[]): number`
  * ✅ `interface Props { readonly items: readonly Item[]; }`

* use **`import type`** for type-only imports [\[1\]](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export)
  * signals intent (purely a compile-time symbol)
  * fully erased at runtime (cleaner bundles, no accidental side effects)

<details>

<summary><strong>Example (<code>import type</code>)</strong></summary>

```typescript
// ❌ regular import for type-only usage
import { User } from "./user";

// ✅ type-only import
import type { User } from "./user";

// ✅ mixed -- type and value
import { fetchUser, type User } from "./user";
```

</details>

* prefer **built-in utility types** over manually rewriting shapes [\[1\]](https://www.typescriptlang.org/docs/handbook/utility-types.html)
  * `Pick<T, K>` / `Omit<T, K>` -- subset of fields
  * `Partial<T>` / `Required<T>` -- toggle optionality
  * `Record<K, V>` -- typed maps
  * `ReturnType<typeof fn>` -- derive return type from a function
  * `Parameters<typeof fn>` -- derive args from a function
  * `Awaited<T>` -- unwrap promises

* prefer **`satisfies`** over `as` for literal expressions [\[1\]](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)
  * validates the value matches a shape, **without** widening to that shape (preserves literal/narrow types)

<details>

<summary><strong>Example (<code>satisfies</code>)</strong></summary>

```typescript
type Config = Record<string, string | number>;

// ❌ `as` -- widens, loses literal types
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
} as Config;
config.apiUrl.toUpperCase(); // ❌ string | number -- can't call .toUpperCase()

// ✅ `satisfies` -- validates, preserves literal/narrow types
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
} satisfies Config;
config.apiUrl.toUpperCase(); // ✅ string -- narrow type preserved
config.timeout.toFixed(2);   // ✅ number -- narrow type preserved
```

</details>

* define data shapes with **Zod schemas**; derive types via `z.infer<typeof Schema>` (single source of truth) [\[1\]](https://zod.dev/)

<details>

<summary><strong>Example (Zod-first)</strong></summary>

```typescript
import { z } from "zod";

// ✅ schema is the source of truth
export const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
});

export type TDocUser = z.infer<typeof userSchema>;

// ❌ don't maintain a hand-written type alongside the schema
// (they will drift)
```

</details>

* enable **strict** type checking in `tsconfig.json` [\[1\]](https://www.typescriptlang.org/tsconfig#strict)
  * `"strict": true` (turns on `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, etc.)
  * `"noUncheckedIndexedAccess": true` -- index access (`arr[0]`, `obj[key]`) returns `T | undefined`
  * `"exactOptionalPropertyTypes": true` -- distinguishes "missing property" from "property set to `undefined`"
