TypeScript does catch errors before runtime — but it only does so reliably when it is configured and used correctly. The default settings are permissive enough that you can write TypeScript that provides almost no type safety at all. Several common patterns actively undermine the benefits of the type system. And there are a handful of features —
as const,never, the exhaustive check pattern — that solve real problems once you know they exist.These are the ten lessons that would have made the learning curve shorter and produced better code earlier.
What this covers:
Strict mode and why to enable it from day one
Why
anydefeats the type system and what to use insteadWhen to annotate and when to trust inference
as constfor literal types and immutable dataUnion and intersection types for modeling state
Generics without the confusion
Built-in utility types worth knowing
The
neverexhaustive check patternType checking in CI
Why TypeScript does not replace tests
1. Enable Strict Mode from Day One
TypeScript's default configuration is deliberately permissive to ease migration from JavaScript. For a project starting from scratch, this permissiveness teaches habits that require unlearning later.
Enabling "strict": true in tsconfig.json activates the full set of safety checks:
{
"compilerOptions": {
"strict": true
}
}
Strict mode includes strictNullChecks (null and undefined are distinct types), noImplicitAny (all parameters must have types), and several others. These are the checks that prevent the most common categories of runtime errors.
Starting with strict mode means encountering these constraints while the codebase is small. Adding it to an existing large codebase produces hundreds of errors at once.
2. Avoid any — Use unknown Instead
any is an escape hatch from the type system. A variable typed as any accepts any value and provides no type information when it is used. TypeScript stops checking it. This is occasionally necessary during migration, but using it as a default when a type is difficult to express defeats the purpose of the tool.
unknown is the safer alternative. It also accepts any value, but TypeScript requires a type check before it can be used:
function processInput(value: unknown): string {
if (typeof value === "string") {
return value.toUpperCase(); // safe to use as string here
}
if (typeof value === "number") {
return value.toFixed(2);
}
throw new TypeError(`Unexpected input type: ${typeof value}`);
}
The constraint is the feature: unknown forces explicit handling of each possible type, which is exactly what makes error-prone paths visible.
3. Trust Type Inference
TypeScript infers types from initialisations, function return paths, and the context in which expressions appear. Annotating every variable and function return type manually produces verbose code and, in some cases, works against inference by providing a less specific type than TypeScript would derive on its own.
// Annotation is redundant — TypeScript infers number
let count: number = 0;
// TypeScript infers string without help
const name = "Ada";
// TypeScript infers the return type as boolean | null
function check(value: string) {
if (value.length > 0) return true;
return null;
}
Explicit annotations are worth adding where the inferred type is inaccurate, where a function forms part of a public API, or where the type communicates intent that inference does not capture. For local variables and simple functions, let TypeScript do the work.
4. Use as const for Literal Types and Immutable Data
When a value is assigned to a let variable, TypeScript widens the type. "admin" becomes string. 42 becomes number. This is correct for a mutable variable but loses precision for a constant.
as const preserves the literal type:
const roles = ["admin", "user", "guest"] as const;
// Type: readonly ["admin", "user", "guest"]
const config = {
env: "production",
port: 3000,
} as const;
// Type: { readonly env: "production"; readonly port: 3000 }
This is particularly useful when the values are used in a union type or as keys in a Record. Without as const, a string array has type string[] and cannot be used to derive a union type of its members. With it:
type Role = typeof roles[number];
// Type: "admin" | "user" | "guest"
5. Model State with Union Types
Multiple boolean flags to represent a single piece of state is a pattern that TypeScript's union types replace cleanly. A loading state with separate isLoading, isError, and isSuccess booleans allows invalid combinations — isLoading: true and isSuccess: true simultaneously, for instance.
A discriminated union makes invalid states unrepresentable:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
The data field only exists on the success state. The message field only exists on the error state. TypeScript enforces this in every switch or conditional that handles the union.
Intersection types compose multiple types into one:
type WithTimestamps = {
createdAt: Date;
updatedAt: Date;
};
type UserRecord = User & WithTimestamps;
6. Generics Are Just Parameterized Types
Generics look intimidating at first but they are a straightforward concept: a type parameter that lets a function or type work with different concrete types without losing type information.
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // T is number, result is number | undefined
const str = first(["a", "b"]); // T is string, result is string | undefined
Without generics, first would need to return any (losing type information) or be written separately for each type (defeating reuse). With generics, one function works for all types and TypeScript knows the specific return type at each call site.
Constraints narrow what T can be:
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // works — string has length
getLength([1, 2, 3]); // works — array has length
getLength(42); // error — number has no length
7. Learn the Built-In Utility Types
TypeScript ships with a set of utility types that transform or compose existing types without requiring manual interface definitions. The ones worth knowing first:
Partial<T>— makes all fields optionalRequired<T>— makes all fields requiredPick<T, K>— creates a type with only the specified keysOmit<T, K>— creates a type with the specified keys removedReadonly<T>— prevents reassignment to any fieldRecord<K, V>— maps a set of keys to a value typeReturnType<F>— extracts the return type of a function
type UserUpdate = Partial<Omit<User, "id">>;
// All User fields optional, id excluded — a standard patch payload type
These types compose. A few combined produce precize transformations that would otherwize require separate interface definitions.
8. Use never for Exhaustive Checks
never is the type that represents an unreachable value — a value that should never exist at a given point in the code. Its most practical use is in exhaustive switch statements over a union type.
type Status = "loading" | "success" | "error";
function handleStatus(status: Status): string {
switch (status) {
case "loading":
return "Loading...";
case "success":
return "Done";
case "error":
return "Something went wrong";
default:
const _check: never = status;
throw new Error(`Unhandled status: ${_check}`);
}
}
If a new value is added to the Status union without updating this function, the assignment const _check: never = status produces a type error. TypeScript knows that status can no longer be never at that point because there is an unhandled case.
This pattern ensures that adding to a union type surfaces every place in the codebase that needs to be updated.
9. Run Type Checking in CI
Editor integration gives immediate feedback while writing code, but it is not a reliable gate. TypeScript errors in files that are not currently open are not visible. A developer in a hurry may ignore or suppress a warning. CI runs tsc against the entire codebase and fails the build if there are any errors.
npx tsc --noEmit
The --noEmit flag runs the type checker without producing output files, which is appropriate for a CI check. Add it to the test or build step so type errors block deployment:
// package.json
"scripts": {
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm test"
}
Pairing tsc --noEmit with eslint-plugin-react-hooks or other ESLint rules covering code quality concerns that TypeScript does not address gives a thorough static analysis step in CI.
10. TypeScript Does Not Replace Tests
TypeScript eliminates an entire category of bugs — type mismatches, incorrect property access, null reference errors in typed paths. It does not verify that the logic is correct.
A function that correctly adds two numbers according to TypeScript's type system can still return the wrong result. An API handler with perfect types can still apply business rules incorrectly. A component with fully typed props can still render the wrong content for a given state.
Unit tests verify that functions produce the expected output for a given input. Integration tests verify that components interact correctly. End-to-end tests verify that user workflows complete successfully. TypeScript and tests address different failure modes and both are necessary.
Key Takeaways
Enable
"strict": trueintsconfig.jsonfrom the start. The constraints it enforces are what make TypeScript useful.Use
unknowninstead ofanywhen a type is genuinely unknown. It requires explicit type checks before use, which is the correct behavior.Trust inference for local variables and simple functions. Add explicit annotations for public APIs and where inference would produce an inaccurate type.
Use
as constto preserve literal types and derive union types from arrays and objects.Model state with discriminated unions rather than boolean flag combinations. Invalid states become unrepresentable.
Generics make functions and types reusable without sacrificing type information.
Built-in utility types (
Partial,Pick,Omit,Record,ReturnType) reduce the need for manual interface definitions.The
neverexhaustive check pattern surfaces missing switch cases as type errors when a union is extended.Run
tsc --noEmitin CI so type errors block deployment regardless of editor behavior.TypeScript prevents type errors. Tests verify correctness. Both are required.
Conclusion
TypeScript's value compounds as a codebase grows. The habits formed early — strict mode on, any avoided, inference trusted, state modeled with unions — produce a codebase that is easier to refactor, easier to extend, and easier for new developers to understand. The habits that undermine it — permissive config, any as a default, annotations that duplicate what inference already knows — produce code that looks typed but provides little of the benefit.
Starting right is significantly easier than correcting later.
A specific TypeScript lesson or pattern that changed how you write code? Share it in the comments.




