The rules below are the ones that prevent the most bugs in real TypeScript code. Each shows the common version first and the better one second, in code you can run. The first rule matters most: stop using any.
Use unknown Instead of any
any switches off type checking for a value and for everything computed from it. unknown also accepts any value, but you have to check it before using it, which puts the check where the data enters your program:
The boundaries are the places where types stop being guaranteed: JSON.parse, fetch responses, localStorage, form input, environment variables and messages from other processes. Validate there with a type guard or a schema library, and the rest of the code can trust its types.
Keep strict On
strict is the default in TypeScript 7; do not turn it off. Add the checks it leaves out that catch the most bugs:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}
noUncheckedIndexedAccess makes arr[i] and record[key] include undefined, which is what they return for a missing index. noImplicitOverride makes a subclass method say override, and noFallthroughCasesInSwitch rejects a case that runs into the next one.
Let Inference Work
Annotate what TypeScript cannot know: function parameters, and the return types of functions other modules use. Leave local variables and callback parameters to inference. An unneeded annotation is not only noise; it can make a type wider than the value:
Without the @ts-expect-error comment, setStatus(annotated) is error TS2345. The inferred const keeps the literal type "active", so it is accepted. Hover a variable in your editor to see what was inferred before you add a type.
Prefer Union Types to Enums
A union of string literals gives autocomplete and exhaustive checks with no generated code. When you also need the list of values at run time, derive the type from an as const array:
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
function canEdit(role: Role): boolean {
return role !== "viewer";
}
console.log(canEdit("editor")); // true
console.log(ROLES.filter(canEdit)); // [ 'admin', 'editor' ]
canEdit("owner"); // error TS2345: Argument of type '"owner"' is not assignable to parameter of type '"admin" | "editor" | "viewer"'.
An enum Role { Admin, Viewer } compiles to an object with a reverse mapping, and a numeric enum parameter accepts any number variable, even one holding a value the enum does not list. Enums also cannot run under Node's type stripping (TypeScript enum is not supported in strip-only mode). The trade-offs are compared on the enums page.
Check Config Objects with satisfies
Annotating an object with a wide type like Record<string, Route> checks its values but forgets its keys. satisfies checks the same thing and keeps the exact type:
Use an annotation when the variable should have exactly the declared type (a function parameter, a value you will reassign). Use satisfies for lookup tables, route maps, theme tokens and other constant objects.
Model State with Discriminated Unions
A single object with optional fields allows states that cannot happen: loading: true together with an error, or data missing after success. A union of objects with a shared tag allows only the real states, and each branch sees only its own fields:
The never line is the exhaustive check. When someone adds a state and forgets to handle it, the build breaks at that line:
The error is index.ts(14,13): error TS2322: Type '{ status: "cancelled"; }' is not assignable to type 'never'. It names the unhandled case. Add case "cancelled": and it compiles.
Avoid ! and as
The non-null assertion x! and the type assertion x as T tell the compiler to stop checking. Neither changes the value at run time, so a wrong assertion becomes a crash later, far from its cause:
Replace ! with ?., ??, an early return, or a thrown error with a useful message. Replace as with a type guard that actually tests the value. The one assertion that is always safe is as const, because it only makes a type narrower and read-only. A good lint setup (typescript-eslint's no-non-null-assertion and no-explicit-any) flags the rest.
Make Data readonly
Mark properties and arrays readonly when code should not change them. The compiler then rejects push, sort and assignments, and functions return new values instead of mutating their inputs:
readonly is checked only at compile time and only one level deep: it does not freeze the object at run time. That is still enough to catch the accidental mutation of shared state that causes most of these bugs.
Frequently Asked Questions
Should I use any in TypeScript?
Almost never in application code. any turns off checking for the value and everything derived from it. Use unknown for values whose type you do not know yet, and narrow them with checks; keep any for rare escape hatches, with a comment explaining why.
Should I annotate every variable in TypeScript?
No. Let TypeScript infer local variables and callback parameters. Annotate function parameters (they cannot be inferred), and the return types of exported functions, so a change inside the function cannot silently change its public type.
Are TypeScript enums bad practice?
Not wrong, but many teams avoid them. An enum generates run-time code, cannot run under Node's type stripping, and a numeric enum parameter accepts any number variable, whatever value it holds. A union of string literals, or an as const array with a derived type, gives the same autocomplete and checks with no generated code.
When should I use type assertions with as?
Only when you know something the compiler cannot, and preferably right after a check that proves it. as changes no value at run time, so {} as User compiles and then has no name. A type guard that tests the value is the safer tool in most cases.
What tsconfig settings are best for a new TypeScript project?
Keep strict on (the TypeScript 7 default) and add noUncheckedIndexedAccess. Many projects also enable noImplicitOverride, noFallthroughCasesInSwitch and verbatimModuleSyntax. The config tsc --init writes sets strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes and verbatimModuleSyntax, among others.