Writing
Getting Started with TypeScript
A practical introduction to TypeScript for JavaScript developers, from your first annotation to safer API boundaries.
unknown ? Job[] ✓ TypeScript is a superset of JavaScript that adds a static type checker. The checker runs before your code reaches the browser, which means some mistakes become editor errors instead of production bugs.
The useful part is not the syntax. It is the feedback loop: you describe the shape of your data, and the compiler tells you where your assumptions break.
A type is not a note to the compiler. It is a claim about what the code is allowed to assume.
Start with one file
You do not need to convert an entire project. Install TypeScript, add a config, and start with a single module.
npm install --save-dev typescript
npx tsc --init
A practical starting tsconfig.json for a browser project looks like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
strict is the important line. Turning it on later is much more work than starting with it. If you are migrating an existing codebase, you can enable strict mode gradually, but do not leave it off forever.
Annotate boundaries, not everything
A common beginner mistake is annotating every local variable. TypeScript can infer most of those types. The annotations that matter are usually at the boundaries: function parameters, return values, API responses, and shared data structures.
type Budget = {
category: string;
spent: number;
target: number;
};
function remaining(budget: Budget): number {
return budget.target - budget.spent;
}
That function now documents its contract. If someone passes an object without target, the compiler points at the call site instead of waiting for NaN to appear in the UI.
Use unions for state
A single object with several optional fields is a common source of impossible states. Consider a data fetch:
type BadState = {
loading?: boolean;
error?: string;
jobs?: Job[];
};
This type allows loading: true, error: 'Failed', and a populated jobs array at the same time. The UI then needs defensive checks for combinations that should never exist.
A discriminated union is clearer:
type JobsState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; jobs: Job[] };
Now the compiler can narrow the state:
function renderState(state: JobsState) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return 'Loading jobs…';
case 'error':
return state.message;
case 'success':
return `${state.jobs.length} jobs found`;
}
}
If you add a new status later, the compiler can help you find every place that needs an update. That is the real benefit: the type system keeps refactoring honest.
Do not trust any
any disables the checker for a value. It is sometimes useful during a migration, but it spreads quickly because every value derived from an any is also unchecked.
Prefer unknown at the boundary and narrow it explicitly:
function isJob(value: unknown): value is Job {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'title' in value &&
typeof value.id === 'string' &&
typeof value.title === 'string'
);
}
const data: unknown = await response.json();
const jobs = Array.isArray(data) ? data.filter(isJob) : [];
This is more code than a cast. It is also the difference between handling bad data and crashing on it.
Generics are for relationships
Generics are not just a way to avoid repeating a type. They describe a relationship between inputs and outputs.
function firstItem<T>(items: T[]): T | undefined {
return items[0];
}
const firstJob = firstItem(jobs);
firstJob is a Job | undefined because the input was Job[]. No annotation is needed. If you find yourself adding generics that are never inferred from an argument, the abstraction is probably too clever.
Migrate in small steps
If you have a JavaScript codebase, this order works well:
- Add TypeScript with
allowJsandcheckJs. - Convert shared data modules first.
- Add types to API calls and component props.
- Enable
strictfile by file. - Remove
anywhere it hides real uncertainty.
The goal is not to make the compiler happy. The goal is to make the assumptions in your code visible before users find them.
Where to go next
Once the basics click, learn narrowing, satisfies, utility types, and schema validation at runtime. TypeScript cannot validate data that arrives over the network. Pairing static types with a runtime check is what makes the boundary safe.
The first week of TypeScript feels like extra work. The second week feels like a second pair of eyes that never gets tired.