How do I type JSON from an external API safely?
Parse, do not cast. A cast is a promise you cannot keep:
const User = z.object({ id: z.number(), email: z.string().email() });
type User = z.infer<typeof User>;
const user = User.parse(await res.json());
The runtime check is the point: as User compiles happily and fails at three in the morning instead.
Is unknown really better than any?
Yes, because it forces a check before use. any disables the checker silently and spreads through everything it touches; unknown keeps the value opaque until you narrow it, which is exactly the discipline you wanted when you reached for a type.
How do I model loading and error states without booleans?
A discriminated union removes impossible states:
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "ok"; data: User[] }
| { status: "error"; message: string };
With three booleans you can represent loading and error simultaneously; with this you cannot, and the compiler enforces handling each case.
How long does this usually take in practice?
An afternoon to do it properly the first time, then minutes when it recurs. Most of the first afternoon goes into discovering which of the defaults do not match your situation.