TypeScript is not a personality and it is not a substitute for tests. It is a way to make illegal states harder to represent and to make refactors cheaper. Teams get this wrong in two directions: any everywhere, or types so clever that nobody can add a field without a ceremony.
Types earn their keep when they catch a real mistake before runtime, when they document a contract, and when they do not slow the team to a crawl. This is how to write that kind of TypeScript in an app, not in a puzzle contest.
any, unknown, and the lie of strict mode
strict: true with any on every parameter is a linter sticker. unknown is the honest “we must narrow.” Narrow with typeof, in, or a type guard that actually checks.
If you receive JSON from the network, it is unknown until you validate. Zod, Valibot, or a hand-rolled guard beats as User. A cast is a note to the compiler to stop talking. The runtime never got the note.
as is allowed when you know something the compiler cannot, and you can say why in a comment. as on every line is how you recreate JavaScript with extra syntax.
Model the domain, not the table
A User with passwordHash on the frontend type is a leak. A User with 40 optional fields because the list endpoint returns five of them is a lie. Use UserListItem and UserDetail. Duplicate the small shapes. DRY at the wrong layer is how invalid states sneak in (email?: on a type that always has email in this view).
Unions beat booleans: status: 'open' | 'closed' rather than isClosed: boolean plus isArchived: boolean that can both be true. Discriminated unions ({ type: 'success', data } | { type: 'error', code }) make exhaustive switch possible. If you add a variant, the compiler finds the missed case. That is TypeScript paying rent.
null vs undefined vs missing key: pick a convention. Optional ? is undefined when missing. null is “we know it is empty.” Mixing them is the JSON article’s cousin.
Don’t type the framework badly
If React already types useState, do not wrap everything in custom IUseState. If you need a branded UserId, brand it at the boundary and pass UserId, not string, through the app. Branded types are useful for not sending an OrderId to a function that wanted UserId. They are not useful on every string.
Event handlers: use the React types. Do not any the event so you can read value. ChangeEvent<HTMLInputElement> exists.
Generics when they remove duplication of meaning
function first<T>(items: T[]): T | undefined is worth it. A 40-line generic mapper with five type parameters that only you understand is not. If the team cannot call it without copying an old call, it is too clever.
Prefer inference. If you must write fetchJson<User>(url) and you already validate, the generic should match the validator’s output, not a parallel type you will forget to update.
Utility types without a zoo
Pick, Omit, Partial are fine. Partial<User> for a patch endpoint is almost right and dangerous if id should not be optional in that position. Make an explicit UserPatch. A little duplication is cheaper than a bad Partial.
ReturnType and Parameters are useful at glue layers. If you need them in domain code, the function might be the wrong abstraction.
Typing CSS and env
process.env.API_URL is string | undefined. Check it at startup. Do not ! it in twelve files. One getConfig() that throws if missing is enough.
CSS modules and class names: if you fight the type of className, you are in the weeds. string is fine.
Speed and the project
Huge unions generated from a giant OpenAPI can freeze the editor. Split types. Don’t import the entire database schema into a frontend bundle’s types if you only need three fields—types are compile-time, but the tooling still parses them.
skipLibCheck is a tradeoff you might need. It is not a substitute for fixing any in your code.
Errors as types
A function that returns User | null forces every caller to handle null. That is good. A function that throws and is typed as User is a lie. Either throw and document, or return a result union. Pick one per layer. Mixing “maybe throw, maybe null, maybe undefined” is how you miss a case.
A practical bar for review
- No new
anywithout a reason - Network data validated or narrowed
- Discriminated unions for states the UI switches on
- No
Partialof a giant type for a form - Ids branded only where mixups have happened or would be catastrophic
If the types make a simple change touch twelve utility types, you overbuilt. If a rename of a JSON field does not fail the build, you underbuilt.
Libraries and DefinitelyTyped
If a library’s types are wrong, you can wrap it once at the boundary with a small module you control. Do not sprinkle as any at every call site. When DefinitelyTyped lags, a local *.d.ts that only types the methods you use is enough. Do not type the entire unused surface of a SDK.
satisfies is useful for checking an object against a type without widening. Use it for route maps and theme tokens. It is not required for every object.
Exhaustiveness and never
A switch that returns never in the default, or const _exhaustive: never = x, is how you get a compile error when a new union member appears. Use that for UI states and for API code unions. Do not use it to impress anyone. Use it so a new payment_failed reason cannot silently fall through to a generic toast.
Component props and children
If every prop is optional, the component has no contract. Required props for the cases you actually use, and a union of variants (type: 'icon' | 'label') beat eight booleans. children?: ReactNode is fine. children: string when you pass elements will annoy you until you fix it.
Avoid React.FC if your team has already dropped it; it is not required. Consistency matters more than the latest Twitter opinion. Follow the repo.
Migration from JS
Do not add allowJs and then never convert. Convert the files you touch. // @ts-check in JS is a halfway house for scripts. For app code, a real tsconfig and incremental conversion is how you get value. A single index.ts that re-exports any is not a migration.
Enums vs unions
TypeScript enums have quirks (numeric reverse mappings, extra runtime). String unions ('open' | 'closed') are usually enough and serialize cleanly. If the team already uses enums, follow the file. Do not convert the codebase in a bugfix PR.
Template literal types, lightly
They are useful for route strings and CSS-like tokens. They are easy to overuse. If the error message is unreadable, you went too far. A simple union of routes is enough for most apps.
Artikals is a home for this middle path because Medium and DEV are full of either “TypeScript sucks” or “here is a monad.” You need neither. You need types that fail the build when the API changes and stay out of the way when you rename a local variable. That is earning their keep. Everything else is sport.