Types end where your program does
Inside the line, a type is a fact the compiler has verified. Outside it, a type is an assumption. Nearly every "TypeScript lied to me" story is a value that crossed the line without being checked.
outside │ inside
network, form, storage, │ your functions, your state,
URL, worker message, │ your components
a library's any │
────────── validate ────────┼──── types are enough ──────
zod / valibot / a predicate │ no runtime checks needed
Validate once, at the edge
const DoorSchema = z.object({
id: z.string(),
name: z.string().min(2),
days: z.number().int().min(1).max(365),
price: z.number().positive(),
});
export type Door = z.infer<typeof DoorSchema>; // one declaration, both jobs
export async function loadDoor(id: string): Promise<Door> {
const res = await fetch(`/api/doors/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return DoorSchema.parse(await res.json()); // throws with a useful message
}
The four escape hatches, and what each costs
| What it does | When | |
|---|---|---|
as | Silences one check | Your own markup, a library that is too loose |
as any | Turns checking off, and it spreads | Almost never — prefer as unknown as T |
! | Asserts not-null | When you have just checked, one line above |
@ts-expect-error | Allows the next line to fail | With a comment saying why — and it errors when the problem is fixed |
They do the same thing, except that expect-error fails the build once the line stops erroring — so it cleans itself up instead of hiding a fix forever.
Migrating JavaScript
- Rename leaf files first — the ones nothing imports. Then work up towards the shared model.
// @ts-checkat the top of a.jsfile gives you most of the value with no rename at all.- Type the shared shapes first: the model, the API responses. They pay for themselves everywhere.
- Do not chase
anyout of a file you are not otherwise touching. Convert as you change.
{
"allowJs": true, // 1. compile the JS as it is
"checkJs": false, // 2. turn on per file with // @ts-check
"strict": false // 3. then strict, one flag at a time
}
Types are not tests, and tests are not types
A type proves the shape; a test proves the behaviour. Replacing a test with a type loses the behaviour, and replacing a type with a test means writing the same assertion in thirty places.
What types do remove is a whole genus of test: the ones that assert an argument is a string, or that a function was called with the right shape. Keep the tests about what the code decides.
Habits worth having
- Annotate the boundaries — exported functions, module edges — and let the middle infer.
- Let the model be one type, derived everywhere else with
Omit,PickandPartial. - Reach for a union of literals instead of a
stringwhenever a value is "one of these". - Make impossible states impossible: a discriminated union rather than four booleans.
- Delete a type parameter that appears once, and a cast you cannot justify in a sentence.
And the ones to avoid
function handle(data: any) { … } // it spreads from here
const door = data as Door; // a wish about a server response
// @ts-ignore // no reason, no expiry
type Props = { [key: string]: any }; // a type that says nothing
enum Finish { Painted, Oiled } // prefer a union of literals
A numeric enum is not type-safe (any number assigns to it), and both kinds emit real JavaScript, which is why a union of string literals with as const is the modern default.
Where it actually pays
The value is not in the annotations; it is in the refactor. When the model gains a field, the compiler hands you the list of everything that must change — and that list is complete, which no search and no memory ever is.
That is the whole argument. Everything else in this course is how to keep that list accurate.