TypeScript pays off most when you let it do two things well: enforce strict typing at the edges of your app and narrow values as you move through real control flow. The first part keeps bad shapes out. The second part turns broad unions into safe, precise values the compiler can trust.
That is what makes strict TypeScript feel different from just adding types for decoration. Once strict is enabled, the compiler stops being polite about missing checks, unsafe casts, and ambiguous values. You start seeing the exact places where your code assumes more than it knows.
What strict typing actually changes
The strict flag is not a single rule. It is a bundle of checks that tighten the type system in useful ways. The most visible ones are noImplicitAny, strictNullChecks, and strictFunctionTypes, but the overall effect is the same: TypeScript makes you prove more before it lets your code compile.
type User = {
id: string;
name: string;
email?: string;
};
function sendEmail(user: User) {
return user.email.toLowerCase();
}
With strictNullChecks on, that code is rejected because email can be missing. That is not TypeScript being annoying. It is TypeScript showing you a bug before production does.
Narrowing is where TypeScript gets useful
Type narrowing is the process of taking a broad type and reducing it to a more specific one based on a check. This is where TypeScript becomes more than static documentation. It starts understanding your control flow.
function formatValue(value: string | number | null) {
if (value === null) {
return 'missing';
}
if (typeof value === 'number') {
return value.toFixed(2);
}
return value.trim();
}
Inside each branch, TypeScript narrows value automatically. null becomes the null branch, typeof value === 'number' narrows to number, and the final branch is left with string.
The checks you reach for most often
The most common narrowing tools are simple, and that is a good thing. You should usually start with language features before you reach for custom helpers.
type ApiResponse =
| { ok: true; data: string[] }
| { ok: false; error: string };
function renderResponse(response: ApiResponse) {
if (response.ok) {
return response.data.join(', ');
}
return response.error;
}
This is a discriminated union. The ok field acts as the discriminator, and once you test it, TypeScript knows which object shape you have.
type SearchResult =
| { kind: 'user'; username: string }
| { kind: 'repo'; stars: number };
function getLabel(result: SearchResult) {
if ('username' in result) {
return 'User: ' + result.username;
}
return 'Repo stars: ' + result.stars;
}
The compiler can narrow by structure, not just by a union tag. That is useful when you do not control the incoming shape or when you are working with legacy objects.
User-defined type guards
Sometimes the built-in checks are not enough. In those cases, a type guard gives you a reusable way to narrow values in a way the compiler understands.
type Article = {
slug: string;
minutesRead: number;
};
function isArticle(value: unknown): value is Article {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.slug === 'string' &&
typeof candidate.minutesRead === 'number'
);
}
That guard turns unknown into Article only after you have checked the properties that matter. It is a better pattern than writing as Article and hoping the data is correct.
function printArticle(value: unknown) {
if (!isArticle(value)) {
return 'Invalid article';
}
return value.slug + ' - ' + value.minutesRead + ' min read';
}
Why unknown beats any
any disables the type system. unknown keeps the type system honest.
function parsePayload(payload: unknown) {
if (typeof payload === 'string') {
return payload.trim();
}
if (Array.isArray(payload)) {
return payload.length;
}
return null;
}
With unknown, you must prove what the value is before you use it. That usually pushes you toward better runtime checks and better API boundaries.
| Type choice | What it does | Best use |
|---|---|---|
| any | Turns off type checking for the value | Last resort only |
| unknown | Requires checks before use | Unchecked inputs and parsed data |
| narrowed union | Lets the compiler infer a precise branch | Real application control flow |
Common narrowing patterns in real code
Most application code needs the same handful of patterns again and again. These are the ones that matter most.
type LoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function renderState(state: LoadState) {
switch (state.status) {
case 'idle':
return 'Waiting';
case 'loading':
return 'Loading...';
case 'success':
return state.data.join(', ');
case 'error':
return state.message;
}
}
That pattern scales well because it is explicit. The switch statement also makes it easier to spot missing cases when you add a new state later.
type Profile = {
name: string;
bio?: string | null;
};
function getBio(profile: Profile) {
if (!profile.bio) {
return 'No bio yet';
}
return profile.bio.trim();
}
Here the check narrows bio from string | null | undefined down to string. That is the kind of small improvement that adds up across a codebase.
Avoid the common escape hatches
The fastest way to lose the value of strict TypeScript is to reach for casts too early.
const value = JSON.parse(input) as { title: string };
That code compiles, but it does not prove anything. It only tells the compiler to trust you. In strict code, that should be the last resort, not the default.
function parseTitle(input: string): string | null {
try {
const parsed: unknown = JSON.parse(input);
if (
typeof parsed === 'object' &&
parsed !== null &&
'title' in parsed &&
typeof (parsed as Record<string, unknown>).title === 'string'
) {
return (parsed as Record<string, unknown>).title;
}
} catch {
return null;
}
return null;
}
That looks longer, but it buys you certainty. The code now fails safely instead of silently assuming the shape is correct.
What strict typing is really buying you
Strict typing is not about making code look academic. It is about forcing ambiguity to show itself early. Narrowing is the tool that lets you turn that ambiguity into useful, specific types without abandoning normal JavaScript control flow.
If you want TypeScript to earn its keep, keep the compiler strict and let narrowing do the precision work. That combination is usually enough to catch the bug before the user ever sees it.