TypeScript Advanced #7: Practical patterns and anti-patterns
Across six posts we covered nearly every tool for shaping types. This last post leaves the tools behind and organizes the sense for separating good types from over-typed ones — starting with the real difference between any and unknown, then moving on to the anti-patterns we fall into and the fixes that fit.
any vs unknown vs never — three danger signals
#
Let’s organize the three most-confused types at once.
| Type | Meaning | Safe? |
|---|---|---|
any | “Turn off all checks for this value” | ✗ dangerous |
unknown | “I don’t know what this is — can’t touch it without narrowing” | ✓ safe |
never | “This value can’t exist” | ✓ intentional |
any — every safety net is off
#
function dangerous(x: any) {
return x.foo.bar.baz(); // compiles — anything can happen at runtime
}
dangerous(null); // passes
dangerous(42); // passes
dangerous('hello'); // passes
When any enters a single place, all autocomplete and type checking from that point disappear. Also, any flows freely into every other type, so a single any can disable the safety nets in other places too.
There are almost no places you should use any. If you see any in new code, almost always there’s a better answer.
unknown — the safe alternative to any
#
unknown says “I don’t know what’s inside,” but before narrowing, almost every operation is blocked.
function safe(x: unknown) {
// x.foo; ✗ unknown — can't access properties
// x(); ✗ can't call
// x + 1; ✗ can't operate
if (typeof x === 'string') {
console.log(x.length); // OK — narrowed to string
}
}Values from outside, like the result of fetch().then(r => r.json()), are always safest received as unknown and narrowed. That’s the pattern from Practice #6.
never — intentionally empty
#
never expresses “this point is never reached” or “this union has no members.”
// 1) exhaustiveness check — pattern from [#5]
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return ...;
case 'square': return ...;
default: {
const _: never = s; // ✗ if a member is missed
return 0;
}
}
}
// 2) function that never returns
function fail(message: string): never {
throw new Error(message);
}never showing up is usually a good signal. It means the compiler is precisely tracking something.
as const — the tool you lose by not using
#
In #1 keyof and typeof we saw the effect of as const. To restate — fix objects and arrays as the literal types you wrote.
const a = ['red', 'green', 'blue'];
// ^ string[]
const b = ['red', 'green', 'blue'] as const;
// ^ readonly ['red', 'green', 'blue']
type Color = (typeof b)[number];
// 'red' | 'green' | 'blue'
Without it, the keyof typeof OBJ pattern from above falls apart. Almost every pattern of “define data in one place and auto-generate types” stands on top of as const.
satisfies — pass the check + keep narrow inference
#
Added in TypeScript 4.9. Checks type compatibility but preserves the variable’s narrow inferred type as-is.
type Routes = Record<string, string>;
// annotation — checked but loses narrow inference
const a: Routes = {
home: '/',
about: '/about',
};
a.home; // string
a.unknown; // string — Record<string, string>, so any key passes
// satisfies — checked, but narrow inference survives
const b = {
home: '/',
about: '/about',
} satisfies Routes;
b.home; // string
b.unknown; // ✗ no key 'unknown'
The difference is the point. With : Routes, the variable’s type becomes Routes itself, so any key passes. satisfies Routes says “only check that this shape is compatible with Routes; the variable’s actual type is what was written.”
Where to use it #
Configuration objects, route maps, action definitions. Cases where “this shape should satisfy some interface, while I want to know the concrete keys and values exactly” meet.
type ActionMap = Record<string, (state: State) => State>;
const actions = {
increment: (s) => ({ ...s, count: s.count + 1 }),
reset: (s) => ({ ...s, count: 0 }),
} satisfies ActionMap;
// actions.increment is exactly (s: State) => State
// actions.unknown is ✗
satisfies feels awkward at first, but once you’re familiar with it, it’s a tool that almost makes type annotations unnecessary.
Anti-patterns — common pitfalls #
Now the anti-patterns. There isn’t always one right answer, but when you see this shape, it’s worth pausing to reconsider.
1) Annotating types everywhere #
TypeScript inference is strong. Annotating everywhere often writes a narrower type than inference produces, and you lose information.
const items: string[] = ['apple', 'banana'];
// inferred string[], but 'apple' | 'banana' might be more accurate
const status: string = 'idle';
// loses narrow inference like 'idle'
Annotate only at external contracts like function parameters and return types; let inference handle variable declarations, callbacks, and other inferable places — usually better.
2) Overusing as casting
#
as Type is a tool to fool the compiler. It compiles but skips runtime checks.
const data = (await res.json()) as User;
// no guarantee the server sent a User shape
const id = parseInt(s) as UserId;
// could actually be number | NaN, but passes
Most uses of as can be replaced with one of:
- A type guard that validates and narrows (
value is X) - An assertion function (
asserts value is X) - A schema like zod for validation
- Branded types + a guard to enforce origin
In DOM manipulation, places like e.target as HTMLInputElement are unavoidable, but if you see as in data flow, reconsider.
3) Giant conditional type chains #
When two or three type-level if-statements stack, code becomes hard to read and edit.
type Foo<T> = T extends string
? T extends `${infer A}-${infer B}`
? A extends 'admin'
? B extends `${number}`
? AdminId<B>
: never
: never
: never
: never;When this shape appears, it’s usually one of two things.
- Genuinely needs to be expressed at the type level (limited to library authors)
- Actually solved better with runtime validation
Most app code is the second. Rather than expensive type tricks, validating at the data entry point with a tool like zod and using ordinary types after is almost always more readable.
4) The Function type
#
You sometimes see code that uses Function itself as a type. This is almost always a trap.
function call(fn: Function) {
return fn(1, 2, 3); // any args pass — not safe
}Instead, specify the call signature.
function call(fn: (...args: unknown[]) => unknown) {
return fn(1, 2, 3);
}
// or narrower
function call<T>(fn: (a: number, b: number) => T): T {
return fn(1, 2);
}This is why TypeScript ESLint’s default rule @typescript-eslint/ban-types blocks Function.
5) Object / {} types
#
Object and {} accept almost any value (except null/undefined).
function f(x: {}) {
// x is almost any value — only excludes null/undefined
// effectively no safety net
}
f(42);
f('hello');
f({ id: 1 });
f(true);If your intent was “I want to receive an object,” Record<string, unknown> or a more specific shape is safer.
6) Overusing index signatures #
It’s easy to write { [key: string]: any }. Doing so disables the keyof we saw in #1.
type Bag = { [key: string]: any };
const bag: Bag = { name: 'curtis' };
bag.unknown; // any — any key passes
Prefer explicit shapes, and when dynamic keys are really needed, narrow the key union with Record<'a' | 'b' | 'c', V>.
7) unknown[] over any[]
#
When you don’t know an array’s elements, unknown[] instead of any[] keeps the safety net alive.
function processAll(items: unknown[]) {
for (const x of items) {
if (typeof x === 'string') {
console.log(x.toUpperCase());
}
}
}You can handle the array itself, but to use elements you must narrow. That’s the proper safety mode.
Three criteria for a good type #
Finally, three things to check when judging “is this type good?”
1) Does intent show? — Status = 'idle' | 'loading' | 'done' carries more intent than string. An Email branded type is more accurate than plain string.
2) Is autocomplete good? — When you press . in the editor, meaningful candidates should appear. With any, autocomplete becomes thin.
3) Is refactoring safe? — When you rename a field or change a signature, every affected place should light up red at once.
A type that satisfies all three becomes self-documenting. Reliance on separate comments/docs falls; information gathers in the code.
How far is enough — the cost of types #
One last line on trade-offs. TypeScript tricks aren’t free.
- Compiler time grows
- Coworkers find it harder to read
- Future-you finds it harder to edit
If you’re not a library author, 90% of app code works fine with ordinary types. The tools in this series are to be pulled out only when needed. You absolutely don’t need to put conditional types and mapped types everywhere.
Good criterion — “does this type deliver value commensurate with its cost?” Pull it out only when the answer is yes.
Wrapping up the series #
The tools we organized across the seven Advanced posts:
- keyof and typeof — pulling out types (#1)
- Mapped types — transforming entire object types (#2)
- Conditional types and infer — branching and extraction (#3)
- Template literal types — composing string types (#4)
- Discriminated unions and type guards — safe modeling (#5)
- Modules and .d.ts — handling external types (#6)
- Practical patterns and anti-patterns — good taste (this post)
Almost everything this series covered is stacking tools on top of types you already know. More important than the tools themselves is the sense of when to reach for which tool. When you face a new problem and can quickly say “ah, I can solve this with a mapped type” or “this just needs a plain type alias,” the series has done its job.
The point where TypeScript stops blocking your work and becomes a coworker walking with you — that, ultimately, is what we were aiming for.