Appearance
TypeScript
Type Usage Rules
- Prefer interfaces for objects with behavior (e.g., store state, API models).
- Prefer types for unions, mapped types, and utility compositions.
- Use
unknownwhen the type is not yet known and narrow it with type guards. - Keep DTOs (API response objects) separated in
src/services/api/models/, suffixed withResponse. - Keep type definitions close to usage unless they’re shared across modules.
- Extract complex return types using
ReturnType<typeof fn>instead of duplicating. - Use as const for literal values when creating config objects.
- Document exported types with JSDoc for clarity.
- Prefer
unknownoverany. Useanyonly if absolutely required, with inline lint disable and justification.
Handling any
anyis strongly discouraged, but may be used only whenunknowndoes not fit the case.- In such cases, disable the ESLint rule
@typescript-eslint/no-explicit-anyinline and add a code comment explaining why. - This ensures reviewers and maintainers understand the intent and risk.
Example:
ts
// ✅ justified use of any
// The external library does not provide typings and narrowing is impossible.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseLegacy(input: any) {
return JSON.parse(input as string)
}Naming Conventions
- Interfaces and types: PascalCase.
- Generic type parameters: start with
Tand be descriptive (e.g.,TItem,TResponse). - Enum names: PascalCase; members: PascalCase.
- Utility types: PascalCase with
*Utilor*Helpersuffix if generic.
For query and mutation composable naming conventions, see 01.Naming.
Enums vs Union Types
- Prefer string literal unions over enums—they tree-shake, compose, and narrow better.
- Use enums only when interoperability with an external API or library forces the format.
ts
// ✅ Preferred: string literal union
type Role = 'admin' | 'user'
function canEdit(role: Role) {
return role === 'admin'
}
// ❌ Enum unless an external contract requires it
enum RoleEnum {
Admin = 'admin',
User = 'user'
}
// ✅ Enum acceptable when interoperating with external SDK/flags
enum BackendStatus {
Pending = 'PENDING',
Active = 'ACTIVE',
Suspended = 'SUSPENDED'
}Global Declarations
- All global declarations and type utilities (generics, helpers) must reside in
src/types/. - File names must be camelCase and suffixed with
.d.ts. Example:src/types/utilityTypes.d.ts - Avoid
index.d.tscatch-alls; prefer explicit files per concern. - Use
.d.tsfiles only for ambient declarations (e.g., module shims, global augmentations). - Avoid polluting global scope; encapsulate within
declare globalonly when necessary.
Ambient Module Declarations
- When using third-party libraries without typings, declare them in
src/types/with a descriptive filename. - Keep declarations minimal and documented so other developers understand the intent.
ts
// src/types/vue-shims.d.ts
declare module 'legacy-vue-plugin' {
export function install(): void
}Example:
ts
// src/types/env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_FEATURE_FLAG: string
}Strictness Rules
- Enable
strictmode intsconfig.json; it provides the best safety baseline. - Keep
noImplicitAny,strictNullChecks, and related checks enabled—only disable them with team agreement and a written justification. - If a rule is temporarily relaxed, track it as tech debt to revisit.
Examples
ts
// ✅ ReturnType — safe and DRY
function buildUser(name: string, age: number) {
return { name, age, active: true }
}
type User = ReturnType<typeof buildUser>
// ❌ Don't duplicate return shapes
type UserBad = { name: string; age: number; active: boolean }ts
// ✅ as const — prevents accidental mutation
export const Roles = {
Admin: 'admin',
User: 'user'
} as const
type Role = typeof Roles[keyof typeof Roles]
// ❌ Without `as const`, Role becomes string (too wide)ts
// ✅ unknown with type narrowing
function parseValue(value: unknown): number {
if (typeof value === 'string') {
return Number(value)
}
if (typeof value === 'number') {
return value
}
throw new Error('Unsupported type')
}
// ❌ any loses type safety
function parseValueBad(value: any): number {
return value // no guarantees
}ts
// ✅ justified use of any with comment
// The external library does not provide typings and narrowing is impossible.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function legacyParser(input: any) {
return JSON.parse(input as string)
}Non-Null Assertion (!)
TypeScript allows using ! to tell the compiler that a value is not null or undefined. While convenient, it is dangerous and should be avoided in almost all cases.
Why not to use it
- Silences the type system instead of addressing the actual problem.
- Can hide legitimate
null/undefinedcases, leading to runtime crashes. - Makes code less maintainable—reviewers cannot easily tell if the value is truly safe.
Preferred alternatives
- Type narrowing with explicit checks:
ts
function getName(user?: User) {
if (!user) {
return 'Guest'
}
return user.name
}- Default values:
ts
function greet(user?: User) {
const name = user?.name ?? 'Guest'
return `Hello, ${name}`
}- Optional chaining:
ts
const city = profile?.address?.cityException
- Use
!only if:- You are absolutely sure the value cannot be
null/undefined. - There is no practical way to express this with type narrowing.
- You add a code comment explaining why and disable the ESLint rule for that line:
- You are absolutely sure the value cannot be
ts
// Value is injected by framework lifecycle, guaranteed defined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const el = document.getElementById('root')!Rules of thumb
- Default: never use
!. - Prefer narrowing, optional chaining, or defaults.
- If you must use it, document why and disable the rule only for that line.
Functions
Options Object Pattern
The Options Object Pattern is a function design style where multiple parameters—especially optional ones—are grouped into a single object. This improves readability, avoids confusion when skipping arguments, and makes function calls self-documenting by naming each parameter explicitly.
- Use the Options Object Pattern when a function accepts two or more optional parameters.
- If the function has only two arguments and both are optional, you should use this pattern.
- The interface for the options object must be placed directly above the function.
- The interface name should be the function name in PascalCase, suffixed with
Args. - If the function has only one optional parameter, this pattern should not be applied.
- Unless the options object is too large, prefer destructuring in the function signature for clarity.
Examples
ts
// ✅ Preferred for small sets
interface FetchProductsArgs {
page?: number
pageSize?: number
}
function fetchProducts({ page, pageSize }: FetchProductsArgs) {
// implementation
}ts
// ✅ Non-destructured when options are many
interface ComplexQueryArgs {
page?: number
pageSize?: number
search?: string
sortBy?: string
includeInactive?: boolean
}
function runComplexQuery(args: ComplexQueryArgs) {
const { page, pageSize, search, sortBy, includeInactive } = args
// implementation
}Return Types
TypeScript infers return types (including Promise<...>), so avoid annotating return types by default.
When to annotate explicitly
- Public APIs (shared utils, composables, services): fix the contract to avoid accidental changes.
- Function overloads: each overload needs an explicit return type.
- Type guards: use predicate returns (
arg is Type) to enable narrowing. - Generics where inference is ambiguous or you need constraints.
- Factories/builders where you want to hide internals and expose a stable surface.
- Literal preservation: when you need to prevent widening (
as const) or ensure a specific union.
Object conversion / shaping
- If the returned object isn’t directly inferred (e.g., building a shape step-by-step), prefer constructors/mappers or
satisfiesto validate shape without erasing inference. - If you must coerce, prefer
as InterfaceNameon the final value, not on intermediate steps.
Preferred patterns
- Let TS infer for local/private functions.
- Fix the contract for exported functions (shared across modules).
- For composables, define a stable return type to avoid leaking extra refs in the future.
- For API wrappers, return domain types, not raw DTOs.
Examples
ts
// ✅ Let TS infer (private/local)
function sum(a: number, b: number) {
return a + b
}ts
// ✅ Public API: fix the contract
interface FormatPriceResult {
value: string
currency: string
}
export function formatPrice(cents: number, currency = 'USD'): FormatPriceResult {
// ...
return { value: '$10.00', currency }
}ts
// ✅ Type guard for safe narrowing (explicit predicate)
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}ts
// ✅ Async: inference already yields Promise<User>
export async function getUser(id: number) {
const res = await api.fetchUser(id)
return toDomainUser(res)
}ts
// ✅ Stable composable surface
interface UseCounterResult {
count: Ref<number>
inc: () => void
reset: () => void
}
export function useCounter(): UseCounterResult {
const count = ref(0)
const inc = () => { count.value += 1 }
const reset = () => { count.value = 0 }
return { count, inc, reset }
}ts
// ✅ Validate shape without forcing the return type (keeps inference)
// Useful when building config objects that callers may extend.
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'price', label: 'Price' }
] satisfies Array<{ key: string; label: string }>ts
// ⚠️ If you must coerce, do it once at the end
function buildUser(name: string, age: number) {
const obj = { name, age, active: true }
return obj as User // avoid sprinkling `as` on each property
}Rules of thumb
- Default: no return annotation; let inference work.
- Add return types for exports, type guards, overloads, ambiguous generics, and APIs.
- Prefer
satisfiesto validate shapes without discarding inference. - If coercion is needed, use
as InterfaceNameon the final result, not intermediate parts. - Avoid returning raw DTOs; map to domain types first.
Creating Generic Types & Interfaces
Use generics to make types reusable without sacrificing safety. Keep them minimal, descriptive, and constrained.
Guidelines
- Name parameters descriptively: start with
T, then context (e.g.,TItem,TResponse,TError). - Constrain with
extendsto the minimal surface (e.g.,extends object,extends Record<string, unknown>). - Prefer defaults for ergonomic APIs:
type Result<T = unknown> = …. - Keep count low: 1–2 generic params is ideal; 3+ only if necessary.
- Avoid “mega-generics” that try to fit every use case.
- Don’t use
anyin generics; preferunknown+ narrowing. - Expose variance via constraints, not comments (e.g.,
extends { id: string }). - Pick type alias vs interface:
interfacefor object contracts you may want to extend/merge.typefor unions, mapped/conditional types, or composition.
- Prefer generic functions over generic variables when possible for better inference at call sites.
- Document parameters with JSDoc so intent is clear during reviews.
- For Options Object Pattern, keep generics on the function, not the
Argstype, unless the options shape truly depends onT.
Examples
ts
// ✅ Minimal, constrained, with default
type ApiResult<TData = unknown, TError = Error> = {
data: TData | null
error: TError | null
}ts
// ✅ Narrow to the minimum needed: must have an id
interface WithId { id: string | number }
function findById<T extends WithId>(list: T[], id: T['id']): T | undefined {
return list.find(item => item.id === id)
}ts
// ✅ Generic utility with unknown + narrowing
function firstOf<T>(arr: readonly T[]): T | undefined {
return arr[0]
}ts
// ✅ Conditional type to derive a Result-like surface
type Ok<T> = { ok: true; value: T }
type Err<E> = { ok: false; error: E }
type Result<T, E = Error> = Ok<T> | Err<E>ts
// ✅ Composable return type kept stable (good for public APIs)
interface UseListResult<TItem> {
items: Ref<TItem[]>
add: (item: TItem) => void
clear: () => void
}
function useList<TItem>(): UseListResult<TItem> {
const items = ref<TItem[]>([])
function add(item: TItem) { items.value.push(item) }
function clear() { items.value = [] }
return { items, add, clear }
}ts
// ✅ Map DTO -> Domain using generics (keeps layers separate)
type Mapper<TIn, TOut> = (input: TIn) => TOut
const toDomain: Mapper<ProductResponse, Product> = (dto) => ({
id: dto.id,
name: dto.name,
priceCents: dto.price_cents,
})ts
// ✅ Mapped type with key filtering
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K]
}ts
// ✅ Defaulted generic to simplify call sites
type Paginated<T = unknown> = {
results: T[]
count: number
next?: string | null
prev?: string | null
}Do / Don’t
| Do | Don’t |
|---|---|
Use TItem, TResponse, TError naming. | Use single letters everywhere (T, U, V) without meaning. |
Constrain with extends to the minimum. | Leave unconstrained and accept anything. |
| Provide sensible defaults for ergonomics. | Force callers to always specify type args. |
| Keep generics small and focused. | Create one generic to handle all scenarios. |
Use unknown + narrowing in APIs. | Use any (erases safety and intent). |
| Make exported generic returns stable. | Leak internal refs/fields unintentionally. |
Use type for mapped/conditional/union. | Force interface when a type is clearer. |
Rationale
- Constrained, descriptive generics keep APIs flexible without turning off type safety.
- Defaults and minimal parameter counts make call sites clean and maintainable.
- Stable exported return types prevent accidental breaking changes in public utilities/composables.
- Mapped/conditional utilities reduce duplication and encode rules in the type system.
- Avoiding
anypreserves safety and communicates intent;unknown+ narrowing keeps APIs honest.
Avoid Inline Types (Prefer Named Types)
Inline types (anonymous object/union types written directly in parameters, returns, or variables) make code harder to read, reuse, and refactor. Prefer named interfaces/types that live alongside the code they represent.
Why avoid inline types
- Readability: call sites and signatures stay short; the type’s purpose is named.
- Reusability: the same shape can be imported across modules (DRY).
- Refactorability: changes are centralized; diffs are cleaner.
- Documentation: you can JSDoc the type once; better hover/tooling.
- Testing: easier to mock/construct fixtures based on a named type.
- Consistent contracts: prevents tiny divergences in “almost the same” inline shapes.
Do this
ts
// ✅ Name the parameter + return shapes
interface AddToCartArgs {
productId: number
qty: number
}
interface AddToCartResult {
success: boolean
remainingStock: number
}
export function addToCart({ productId, qty }: AddToCartArgs): AddToCartResult {
// ...
return { success: true, remainingStock: 12 }
}ts
// ✅ Name domain models and reuse
type Role = 'Admin' | 'User'
interface User {
id: number
name: string
role: Role
}
function isAdmin(user: User): boolean {
return user.role === 'Admin'
}Avoid this
ts
// ❌ Anonymous inline param + return types
export function addToCart(
args: { productId: number; qty: number }
): { success: boolean; remainingStock: number } {
// ...
return { success: true, remainingStock: 12 }
}ts
// ❌ Repeating inline shapes across files
function isAdmin(user: { id: number; name: string; role: 'Admin' | 'User' }) {
return user.role === 'Admin'
}When inline types are acceptable
- Trivial, hyper-local helper within a small scope (e.g., inside a single test or a one-off closure).
- Generic constraints that are clearer inline (e.g.,
<T extends { id: string }>). - Literal inference where readability isn’t harmed (e.g.,
as constconfigs). - One-off mapped/conditional types that would lose clarity if extracted.
- Hardcoded string unions (
'read' | 'write' | 'admin') that are not shared—extract only when the union becomes reusable.
ts
// ✅ GOOD: Inline string union scoped to this file only
const accessLevel: 'read' | 'write' | 'admin' = 'read'
// ❌ BAD: define the type local and then use just one time:
type AccessLevel = 'read' | 'write' | 'admin'
const accessLevel: AccessLevel = 'admin'Storage location for named types
- Domain types:
src/models/(shared across modules). - API DTOs:
src/services/api/models/(with*Response/*Request). - Feature-scoped types: colocate in the feature folder (e.g.,
src/pages/<Module>/types.ts). - Global utilities/ambient:
src/types/*.d.ts(for generics, shims, module declarations). - Barrels for types are allowed only when they don’t hide source ownership; prefer importing from the defining file to keep blame clear.
ts
// src/pages/cart/types.ts
export interface CartLine {
id: string
productId: string
qty: number
}
// src/pages/cart/index.ts — optional barrel
export { type CartLine } from './types'
// src/pages/cart/components/CartSummary.ts
import type { CartLine } from '../types' // uses the defining file directly
// src/pages/cart/someOtherFile.ts (only when barrel helps)
import type { CartLine } from './index'Extra tips
- For function parameters using the Options Object Pattern, suffix the type with
Argsand place it above the function. - For public APIs (composables, services), export the named return type to lock the contract.
- Prefer
ReturnType<typeof fn>only when it improves clarity; otherwise, give the shape a name.
Testing Types (@ts-expect-error vs @ts-ignore)
When writing tests or working around TypeScript limitations, you may need to intentionally trigger type errors. TypeScript provides two directives: @ts-expect-error and @ts-ignore. These must be used carefully.
Rules of thumb
Prefer
@ts-expect-errorwhen you want to assert that a line produces a type error.- This is useful in type tests to ensure the type system rejects invalid usage.
- If the error ever stops occurring (e.g., due to refactor), the compiler warns you so tests stay correct.
Avoid
@ts-ignorein almost all cases.- It suppresses errors without tracking them, which can silently mask legitimate issues.
- Only use when:
- The error is a false positive (TS bug or limitation).
- You cannot refactor around it.
- You add a code comment explaining why.
Examples
ts
// ✅ Expected error in type test
// @ts-expect-error — numbers are not assignable to string
const invalid: string = 42ts
// ✅ Justified ts-ignore (rare)
// The library typings are wrong, and a PR has been opened upstream.
// @ts-ignore — waiting for fix in types package
legacyLib.doThing()ts
// ❌ Avoid — hides all errors without context
// @ts-ignore
doSomething(42)Summary
- Default: never silence errors.
- Type tests: use
@ts-expect-error. - Real code: use
@ts-ignoreonly with justification and comment.
Other recommendations
Error Handling Types
- Always type caught errors as
unknownand narrow explicitly:tstry { // logic } catch (err: unknown) { if (err instanceof Error) { console.error(err.message) } }
Component Libraries & Config Objects
- For big prop objects/configs, use
as const+ derived unions:tsexport const ButtonVariants = { Primary: "primary", Secondary: "secondary", Danger: "danger" } as const type ButtonVariant = typeof ButtonVariants[keyof typeof ButtonVariants] - Avoid enums; prefer unions derived from
as constobjects.
TypeScript & Vue Integration
For component-level typing (props, emits, generics), see 03.Components. For composable contracts and stable return ordering, see 04.Composables. For Pinia store typing, see 06.State-Management.
Reactivity Primitives
Use
ref<T>()andcomputed()with explicit generic arguments when inference is ambiguous.Distinguish
Ref<T>(mutable) fromComputedRef<T>(read-only) in type signatures.Type template refs with the DOM node class:
Use DOM types for template refs, e.g.:
tsconst inputRef = ref<HTMLInputElement>()Always narrow event types:
tsfunction onKey(e: KeyboardEvent) { if (e.key === "Enter") { /* ... */ } }Avoid using
anyfor handlers.
Composable Return Types
Export a stable UseXxxResult interface that explicitly lists Ref<T> or ComputedRef<T> fields. Never leak extra refs into the return that consumers did not ask for. The full guidance lives in 04.Composables.
ts
// ✅ Stable surface — consumers know exactly what they get
export interface UseCounterResult {
count: Ref<number>
inc: () => void
}Accepting Reactive Inputs
When a composable must accept refs, getters, or plain values, use MaybeRefOrGetter<T> and unwrap with toValue(…) so the body works with plain values. This generalizes beyond queries.
ts
import { ref, toValue, type MaybeRefOrGetter } from 'vue'
// ✅ Accept a ref, a getter, or a plain value; unwrap with toValue
function useDebouncedValue<T>(source: MaybeRefOrGetter<T>, delay = 200) {
const debounced = ref(toValue(source))
// watch(() => toValue(source), (v) => { /* ... */ })
return { debounced }
}Used for reactive query keys in 05.Queries & Mutations.
Guarding Wrapper Options with Omit
When you wrap a library's options type and control certain keys yourself, strip those keys with Omit<> so callers spreading ...options cannot clobber them.
ts
import type { UseQueryOptions } from '@pinia/colada'
import type { MaybeRefOrGetter } from 'vue'
interface ArticleListQueryKeyArgs {
filters?: MaybeRefOrGetter<{ tag?: string; authorId?: number }>
}
// ✅ Strip 'key' | 'query' — the wrapper owns them; callers can't override via ...options
type UseArticlesQueryArgs =
Omit<Partial<UseQueryOptions<PaginatedArticleList>>, 'key' | 'query'> &
ArticleListQueryKeyArgs
export function useArticlesQuery({ filters, ...options }: UseArticlesQueryArgs = {}) {
// key/query are set here; ...options spreads only the safe remainder
}- With
exactOptionalPropertyTypes: true(see the tsconfig below),Partial<>spreads are stricter — callers cannot passundefinedexplicitly for optional keys. - The concrete query/mutation patterns are documented in 05.Queries & Mutations.
vue-tsc tool is the essential and only reliable way to validate TypeScript in Vue projects. It ensures type correctness across .vue files, which regular tsc cannot fully guarantee.
- This must run in CI for pull requests and daily checks to validate typings.
- Developers are encouraged to run it locally before committing to catch issues earlier, since CI will block merges if typing errors exist.
Recommended command in package.json
json
{
"scripts": {
"type:check": "vue-tsc --noEmit"
}
}Recommended Typescript config
The following baseline config (stored under docs/examples/tsconfig.json) should be used as the project’s root tsconfig.json. It enforces strictness, aligns with Vue 3 + Vite conventions, and provides safe defaults for both TypeScript and JavaScript files.
json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"jsxImportSource": "vue",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"useUnknownInCatchVariables": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"allowJs": true,
"verbatimModuleSyntax": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@test/*": ["test/*"]
},
"types": [
"node",
"vite/client"
],
"lib": ["esnext", "dom", "dom.iterable"],
"noEmit": true
},
"include": [
"src",
"src/types",
"test"
],
"exclude": [
"node_modules",
"dist",
".vite",
"**/*.generated.d.ts"
]
}See docs/examples/ for the full baseline tsconfig.json, eslint.config.mjs, and editor config.
Key Options Explained
allowJs: trueAllows.jsfiles to coexist with.tsfiles. Useful for incremental migration from JavaScript to TypeScript or when third-party code must remain in JS. Keep the scope of JS files limited, otherwise type safety coverage drops.noEmit: truePrevents TypeScript from emitting compiled.jsfiles. Compilation and bundling are handled by Vite, so only type checking is needed.verbatimModuleSyntax: falsefalsedisables the stricter verbatim enforcement, so Vite/esbuild can elide type-only imports while keeping runtime imports intact. This is important for Vue’s<script setup>because imports used only as types are safely removed, while real runtime imports stay intact. See: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#verbatimmodulesyntaxallowSyntheticDefaultImports: trueEnablesimport x from 'cjs-lib'even when the library does not provide a real default export (common in legacy CommonJS packages).esModuleInterop: trueComplements the above, making CommonJS and ESModule interop smoother by generating synthetic default exports.skipLibCheck: trueSkips type checking of.d.tsfiles from dependencies. Speeds up builds and avoids noise from broken typings in external libraries.strict: trueEnables TypeScript’s full strict mode bundle, turning on all core safety checks by default—works in tandem with options likenoImplicitAnyandstrictNullChecks.noImplicitAny: trueDisallows implicitly inferredanytypes, forcing developers to annotate or narrow values deliberately. Often the first strict rule teams try to relax—keep it on to maintain discipline.noUncheckedIndexedAccess: trueForces indexed access (arr[i],obj[key]) to includeundefinedin the type unless explicitly guarded. Prevents runtime crashes from missing values.exactOptionalPropertyTypes: trueDistinguishes betweenprop?: string(can be omitted or undefined) andprop: string | undefined(must be present but can hold undefined). Provides stronger guarantees.useUnknownInCatchVariables: trueMakes catch clause variables default tounknowninstead ofany, requiring developers to narrow them before use.isolatedModules: trueEnsures each file can be transpiled in isolation. Required by Vite and similar bundlers for correct compilation.strictNullChecks: trueEnforces handling ofnullandundefinedexplicitly. Prevents unsafe assumptions.paths&baseUrlSimplifies imports with@/*and@test/*aliases, improving readability and avoiding deep relative paths.
Global Do / Don’t
| Do | Don’t |
|---|---|
Use unknown and narrow with type guards. | Use any without explanation. |
If any is unavoidable, disable lint and document the reason. | Blanket disable @typescript-eslint/no-explicit-any project-wide. |
| Define DTOs with exact API shape (snake_case allowed). | Reuse API DTOs directly in the app domain layer. |
Keep domain types in src/models/ and API DTOs in services/api/. | Mix DTOs and domain types in the same file. |
Use ReturnType<typeof fn> to derive function result types. | Duplicate return object shapes manually. |
Use as const for literal configs. | Use raw strings/numbers where enums or consts fit better. |
Write small, descriptive generics like TItem, TResponse. | Use single-letter generics like T, U, V everywhere. |
| Co-locate types with the module if only used there. | Put all types in a single types.ts dumping ground. |
| Add JSDoc to exported types/interfaces. | Leave types undocumented when used across modules. |
Strip wrapper-controlled keys with Omit<> so spread options can't override them. | Spread caller options over key/query (or any wrapper-owned key) unguarded. |
Global Rationale
- Clear separation of concerns: keeping DTOs in
services/api/modelsand domain types insrc/models/prevents accidental leaks of API-specific structures into the application domain. - Strict naming rules (PascalCase, descriptive generics,
.d.tssuffix) make types instantly recognizable and consistent across the codebase. unknownoveranyenforces type safety while still allowing flexibility. Whenanyis truly required, requiring inline justification ensures transparency and accountability.- Global declarations in
src/types/centralize ambient definitions while avoiding global pollution. - Best practice patterns like
ReturnTypeandas constreduce duplication, prevent bugs, and improve maintainability. - JSDoc comments on exported types make intent explicit, which helps reviewers, maintainers, and newcomers.
- Do/Don’t table provides a quick reference for developers, making the rules easy to follow and enforce.