| description | Reference for a model writing, reviewing, or cleaning up TypeScript - configuration, type discipline, imports, errors, async, validation, naming, testing, linting, and publishing |
|---|
This file equips a model to write, extend, and maintain TypeScript codebases. Read the non-negotiable rules and the closing restatement first; they bind every edit. Sections run from most to least frequently needed during cleanup and are consulted one at a time, so the file's length does not collide with the constraint budget. Target TypeScript 5.8+ with awareness of 6.0 defaults.
Follow these on every change; they are restated at the end.
- Enable
strict: true,noUncheckedIndexedAccess, andverbatimModuleSyntaxin every tsconfig; a project missing any of these is incomplete. - No
anyin application code; receive external data asunknownand validate through a schema library before use. - No
enum; useas constobjects with derived union types. - No barrel files (
index.tsre-exports) in application code; reserve them for published library entry points only. - Use
import typefor type-only imports;verbatimModuleSyntaxenforces this. - Write explicit return types on every exported function; internal functions rely on inference.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"incremental": true
},
"include": ["src"]
}| Flag | Why |
|---|---|
strict: true |
Activates eight core checks; the floor, not the ceiling |
noUncheckedIndexedAccess |
Makes arr[i] return T | undefined; catches out-of-bounds access |
verbatimModuleSyntax |
Forces explicit import type; what you write is what gets emitted |
isolatedModules |
Ensures compatibility with single-file transpilers (esbuild, SWC) |
skipLibCheck |
Biggest single build-speed win; skips checking node_modules .d.ts |
forceConsistentCasingInFileNames |
Catches case-sensitive path bugs on case-insensitive file systems |
- Frontend projects with a bundler:
moduleResolution: "Bundler" - Node.js without a bundler:
moduleResolution: "NodeNext"paired withmodule: "NodeNext" - Never use
"node"(alias for"node10") or"classic"- both deprecated in TS 6.0
Configure in tsconfig via paths. TypeScript's paths only affects type checking - it does not rewrite import paths in emitted code. Sync the same aliases in your bundler separately (Vite: resolve.tsconfigPaths: true; webpack: tsconfig-paths-webpack-plugin).
Remove baseUrl (deprecated in TS 6.0). Prepend its value directly to each paths entry:
{
"compilerOptions": {
- "baseUrl": "./src",
"paths": {
- "@app/*": ["app/*"],
+ "@app/*": ["./src/app/*"],
}
}
}| Option | TS 5.x Default | TS 6.0 Default |
|---|---|---|
strict |
false |
true |
module |
commonjs |
esnext |
target |
es3 |
es2025 |
moduleResolution |
node10 |
bundler |
esModuleInterop |
false |
true |
types |
All @types/* |
[] (empty) |
moduleResolution: "node"->"Bundler"or"NodeNext"- the old value is deprecated and misresolves package.jsonexports.target: "ES5"->"ES2022"- ES5 emit is deprecated in TS 6.0 and adds unnecessary polyfills.- Missing
noUncheckedIndexedAccess-> add it - the flag most teams skip and the one that catches the most bugs. baseUrl: "./src"-> remove and prepend topathsentries - deprecated in TS 6.0.importsNotUsedAsValuesorpreserveValueImports-> remove and useverbatimModuleSyntax- the old flags are superseded.
| Position | Annotate? | Reason |
|---|---|---|
| Exported function return | Yes | Documents intent, catches drift |
| Function parameters | Yes | The contract with callers |
| Public API boundaries | Yes, with : Type |
Deliberate widening for stability |
| Local variables | No | Trust inference; avoids noise |
| Internal function returns | No | Inference is more precise than you are |
| Callback bodies | No | Contextual typing handles it |
| Goal | Construct | Effect |
|---|---|---|
| Validate without widening | satisfies T |
Preserves literal types, autocomplete, exhaustiveness |
| Validate and widen | : T |
Replaces inferred type with stated shape |
| Skip validation (escape hatch) | as T |
Lies to the compiler; use only post-validation |
- Use
satisfiesfor config objects, route tables, lookup maps, feature flags - Use
: Typefor function parameters and public API return types - Use
asonly for DOM operations and unavoidable post-validation casts - Combine:
{ ... } as const satisfies Configfor validated, immutable, narrow structures
Never write value!.prop without prior validation. Replace with narrowing:
// Bad
const name = user!.name;
// Good
if (!user) throw new Error("user required after auth");
const name = user.name;const users: User[] = getUsers()->const users = getUsers()- over-annotation; inference is alreadyUser[].JSON.parse(body) as User->UserSchema.parse(JSON.parse(body))-ason external data provides zero runtime protection.result!.value-> narrowing check before access -!hides a potential crash.data as unknown as Target-> restructure the types or validate - double assertion is always a design error.- Missing return type on
export function fetchUsers()-> addexport function fetchUsers(): Promise<User[]>- exports need explicit returns.
Every enum becomes an as const object with a derived union type:
// Before
enum Status {
Pending = 'pending',
Active = 'active',
Closed = 'closed',
}
// After
const Status = {
Pending: 'pending',
Active: 'active',
Closed: 'closed',
} as const;
type Status = typeof Status[keyof typeof Status];enumemits runtime objects that do not tree-shakeconst enumbreaks bundlers, is invisible to JavaScript consumers, and fails underisolatedModulesas constobjects are plain JavaScript, tree-shake perfectly, need no import for the type, and work withsatisfies
When no runtime object is needed, a bare union suffices:
type Direction = 'north' | 'south' | 'east' | 'west';- Module-scoped constants:
UPPER_SNAKE_CASE - Derive unions from const arrays:
const ROLES = ['admin', 'user', 'guest'] as const; type Role = typeof ROLES[number];
enum Foo { ... }->const Foo = { ... } as constwith derived type - enums are banned.const enum Bar { ... }-> same replacement -const enumbreaks bundlers andisolatedModules.type Status = 'a' | 'b'duplicated alongside a runtime object -> singleas constsource of truth.
- Type-only imports:
import type { User } from './types' - Mixed imports:
import { type User, createUser } from './users' verbatimModuleSyntaxmakes this mandatory - a value import withouttypeis preserved in output; a type import withouttypeis an error
- No
index.tsre-export files insidesrc/ - Barrel files force TypeScript, Jest, and bundlers to process the entire module subtree on any import
- Atlassian measured 75% faster builds after removing them
- Reserve barrel files only for the public entry point of a published package
- One concept per file; split past ~300 lines
- Feature-based grouping (
users/,products/) over type-based (controllers/,services/) - Prefer path aliases (
@/utils) over deep relative paths (../../../utils) - No circular imports - detect with
madge --circularoreslint-plugin-import
import { User } from './types'(whereUseris type-only) ->import type { User } from './types'- required byverbatimModuleSyntax.export * from './foo'in application code ->export { specificThing } from './foo'- wildcard re-exports defeat tree-shaking and hide dependency edges.src/components/index.tsre-exporting everything -> remove; import directly from source files.import { something } from '../../../shared/utils'->import { something } from '@/shared/utils'- path alias is readable and refactor-safe.
Use discriminated unions for expected failures that callers can recover from:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };| Situation | Mechanism |
|---|---|
| Expected business logic failure (not found, validation) | Result<T, E> |
| Unrecoverable programmer error (broken invariant) | throw |
| API boundary response | Typed error response with status code |
Enumerate possible errors for exhaustive handling:
type AppError =
| { code: 'NOT_FOUND'; id: string }
| { code: 'VALIDATION'; field: string; message: string }
| { code: 'UNAUTHORIZED' };neverthrowfor chainableResultwithmap,andThen,ResultAsyncEffectfor full effect system with typed errors, DI, and concurrency
throw new Error('not found')in domain logic -> return{ ok: false, error: { code: 'NOT_FOUND', id } }- expected failures belong in the return type.catch (e: any)->catch (e: unknown)with narrowing -useUnknownInCatchVariablesenforces this under strict mode.- Empty
catch {}-> at minimumcatch (e) { logger.error(e); }- swallowed errors are invisible bugs. throwinside a function returningPromisewithout typed error -> return typed Result or define error response shape.
- Every Promise must be awaited or explicitly detached:
void doWork().catch(logger.error) - Enable
@typescript-eslint/no-floating-promisesandno-misused-promises - No
async voidfunctions; always returnPromise<void> - No
forEachwith async callbacks - it does not await. Usefor...oforPromise.all(items.map(...)) - No mixing
.then()/.catch()withasync/awaitin the same function
| Combinator | Use when |
|---|---|
Promise.all |
All must succeed; fail-fast on first rejection |
Promise.allSettled |
Partial success is acceptable; always resolves |
Promise.any |
First success wins (racing mirrors, fallbacks) |
Promise.race |
First settlement wins (timeout patterns) |
- One
AbortControllerper logical operation (search, checkout, sync) AbortSignal.timeout(ms)for deadlinesAbortSignal.any([userCancel, timeout, routeChange])to compose conditions- Handle
AbortErroras expected control flow, not failure
items.forEach(async (item) => { await process(item); })->for (const item of items) { await process(item); }- forEach does not await.async function handler(): void->async function handler(): Promise<void>-async voidhides rejections.fetch(url)without await or void ->await fetch(url)orvoid fetch(url).catch(...)- floating promise..then(data => { ... }).catch(err => { ... })inside an async function ->try { const data = await ...; } catch (err) { ... }- don't mix styles.
Every trust boundary validates through a schema. TypeScript types are erased at runtime - external data is unknown regardless of compile-time annotations.
Trust boundaries:
- API responses (
fetch,axios) JSON.parseoutput- Environment variables (
process.env) - User input (forms, query params)
- File reads
- Third-party SDK returns
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
role: z.enum(['admin', 'user']),
});
type User = z.infer<typeof UserSchema>;
// At the boundary:
const user = UserSchema.parse(await response.json());| Library | Use when |
|---|---|
| Zod v4 | Default choice; best ecosystem (tRPC, React Hook Form, Vercel AI) |
| Valibot | Bundle-sensitive (edge, Cloudflare Workers); ~1KB shipped |
| ArkType | Best inference; TypeScript expressions as schema |
| TypeBox | JSON Schema native; Fastify/OpenAPI |
Use a validated config module. Never access process.env directly in application code:
// env.ts - validated at import time, fails fast at startup
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
},
});Brand domain scalars that cross boundaries:
declare const Brand: unique symbol;
type Branded<T, K extends string> = T & { readonly [Brand]: K };
type UserId = Branded<string, 'UserId'>;
type OrderId = Branded<string, 'OrderId'>;
function createUserId(raw: string): UserId {
if (!isValidUuid(raw)) throw new Error('Invalid user ID');
return raw as UserId;
}JSON.parse(body) as User->UserSchema.parse(JSON.parse(body))-ason external data is a lie.const port = process.env.PORT->const port = env.PORTvia validated config - raw env access isstring | undefinedwith no coercion.const data: ApiResponse = await res.json()->const data = ApiResponseSchema.parse(await res.json())- annotation on runtime data is false confidence.
| Identifier | Convention | Example |
|---|---|---|
| Variable, function, method | camelCase |
getUserData, isLoading |
| Type, interface, class | PascalCase |
UserProfile, HttpClient |
| Global constant | UPPER_SNAKE_CASE |
MAX_RETRY_COUNT, API_BASE_URL |
| Generic parameter | T or TEntity |
T, TData, TError |
| File | kebab-case |
order-service.ts, use-auth.ts |
| React component file | PascalCase |
UserCard.tsx |
- No
Iprefix on interfaces - modern TypeScript and IDEs make the distinction unnecessary - Booleans:
is,has,should,canprefixes - Functions: verb-first (
calculate,fetch,validate,create) - Event handlers:
onprefix (onClick,onSubmit) - Hooks:
useprefix (useAuth,useFetch) - Types describing function shapes: suffixed with
Fnor describe the action (Comparator,Predicate)
- One concept per file
- Split files past ~300 lines
- Co-locate types with the code that uses them
- Separate
types.tsonly for types shared across 3+ files in the same feature
interface IUser->interface User- no I-prefix.function data()->function fetchData()orfunction getData()- functions are verb-first.const process = true->const isProcessing = true- booleans need their prefix.user-profile.tsx(React component) ->UserProfile.tsx- components use PascalCase filenames.
Apply this section only to React/Preact codebases.
- No
React.FC- use plain function components with typed props and explicit return type - No
defaultProps- use default parameter values in the destructured props - Generic components use function declarations with trailing comma:
<T,>in .tsx files
// Preferred
function UserCard({ name, role = 'user' }: UserCardProps): React.ReactElement {
return <div>{name} ({role})</div>;
}
// Generic component
function List<T,>({ items, renderItem }: ListProps<T>): React.ReactElement {
return <ul>{items.map(renderItem)}</ul>;
}- Use
ComponentPropsWithoutRef<'button'>when wrapping HTML elements - Prefer
React.ReactNodefor children types (explicit, not implicit via FC) - Use discriminated unions for mutually exclusive props
- Use
satisfiesfor context default values
const App: React.FC<Props> = (props) => { ... }->function App(props: Props): React.ReactElement { ... }- FC adds implicit children and complicates generics.Component.defaultProps = { ... }-> default values in destructuring - defaultProps is deprecated.createContext<T>(undefined as any)->createContext<T>(defaultValue satisfies T)- noas anyin context.
- Use
vi.fn<[Args], Return>()with explicit type parameters - Use
vi.mocked<T>()to preserve interface contracts - Use
satisfiesoverasfor test data (validates without widening) - Mock at module boundaries, not internal functions
- Clean up:
afterEach(() => vi.restoreAllMocks())
Built into Vitest via expectTypeOf:
import { expectTypeOf } from 'vitest';
expectTypeOf(myFunc).returns.toBeString();
expectTypeOf(result).not.toBeAny();
expectTypeOf(config).toEqualTypeOf<AppConfig>();Use @ts-expect-error to test that invalid usage correctly fails:
// @ts-expect-error - string not assignable to number
createUser({ age: 'old' });const mock = vi.fn() as any->const mock = vi.fn<[string], Promise<User>>()- typed mocks catch drift.const testUser = { name: 'test' } as User->const testUser = { name: 'test', id: '1', role: 'user' } satisfies User- satisfies catches missing fields.- No type assertions in tests -> use
expectTypeOffor type-level assertions.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
}
);The recommended config is intentionally weak - designed not to annoy beginners. strictTypeChecked uses type information to catch floating promises, unsafe returns, and misused thenables that recommended silently ignores.
| Rule | Catches |
|---|---|
no-floating-promises |
Unhandled async errors |
await-thenable |
Awaiting non-promises |
no-unsafe-return |
Unsafe any propagation |
no-explicit-any |
any in application code |
consistent-type-imports |
Missing import type |
restrict-template-expressions |
Implicit coercion in templates |
switch-exhaustiveness-check |
Missing branches on discriminated unions |
.eslintrc.json->eslint.config.jswith flat config - the old format is deprecated.tseslint.configs.recommended->tseslint.configs.strictTypeChecked- recommended is too weak for production.parserOptions: { project: './tsconfig.json' }->parserOptions: { projectService: true }- projectService is the modern approach with less config.
Apply this section to npm-published libraries.
The types condition must come first in every conditional block. Resolvers match top-down; if import comes before types, TypeScript resolves to the .js file and all types become any.
{
"name": "my-library",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}declaration: true+declarationMap: trueto generate .d.ts automaticallyisolatedDeclarations(TS 5.5+) for parallel generation by non-TypeScript tools- Never hand-maintain .d.ts files for your own library code
- Use
emitDeclarationOnly: truewhen a bundler handles JavaScript output
Run before every publish:
publint- checks package.json correctnessattw(Are The Types Wrong) - checks types resolve correctly for consumerscargo semver-checksequivalent: compare .d.ts between versions
"import": ..., "types": ...->"types": ..., "import": ...- types must come first or consumers getany.- Missing top-level
"types"field -> add it - legacy resolvers ignoreexportsand read only the top-level field. - Hand-maintained
.d.tsalongside.tssource -> delete .d.ts, enabledeclaration: true- generated declarations stay in sync.
tsc --noEmitfor type checking (CI, pre-commit hooks)- esbuild / SWC / Vite for JavaScript output (10-20x faster than tsc emit)
- Both are required; neither replaces the other
| Flag | Impact |
|---|---|
incremental: true |
70-90% faster rebuilds via .tsbuildinfo |
skipLibCheck: true |
Skips checking node_modules .d.ts |
composite: true |
Per-package incrementals in monorepos |
Cache .tsbuildinfo in CI:
- uses: actions/cache@v4
with:
path: .tsbuildinfo
key: tsc-${{ hashFiles('tsconfig.json') }}-${{ hashFiles('src/**') }}- Every package:
composite: truein tsconfig - Root: empty
files: []withreferenceslisting all packages - Build with
tsc -b(walks dependency graph, rebuilds only what changed) noEmitdoes not compose withcomposite- composite projects must emit declarations
tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace- Barrel files (force full subtree compilation on every import)
- Deep conditional types in hot paths
- Unscoped
include(compiling test files during production build) - Missing
incremental(full rebuild on every change)
These change with the toolchain. Verify against the current release before relying on one.
| Feature | Minimum Version |
|---|---|
verbatimModuleSyntax |
5.0 |
moduleResolution: "Bundler" |
5.0 |
const type parameters |
5.0 |
isolatedDeclarations |
5.5 |
${configDir} template variable |
5.5 |
| Inferred type predicates | 5.5 |
erasableSyntaxOnly |
5.8 |
strict: true as implicit default |
6.0 |
baseUrl deprecated |
6.0 |
Prohibits TypeScript constructs that emit runtime code (enum, namespace with runtime members, parameter properties). Required when using Node.js native type stripping (--experimental-strip-types). Pair with verbatimModuleSyntax.
Functions with a single return statement performing a runtime check no longer need explicit value is Type annotations - the compiler infers the predicate automatically. Write explicit predicates only when logic is too complex for inference.
- Enable
strict: true,noUncheckedIndexedAccess, andverbatimModuleSyntaxin every tsconfig. - No
anyin application code; validate external data through a schema library. - No
enum; useas constobjects with derived union types. - No barrel files in application code.
- Use
import typefor type-only imports. - Write explicit return types on every exported function.
2026-07-30 - Opus 4.6 (Cursor agent). Distilled from web research on TypeScript configuration, type discipline, tooling, and the ecosystem (2024-2026 sources).
