Tech & Engineering
Tech & Engineering/7 min read

Stop Using any: A Type Guard Primer

Your linter is right to complain about any. Here's why unknown is the correct choice at type boundaries, and how a single isRecord guard can replace dozens of unsafe casts.

Artiphishle|
typescripttype-safetypatternslintingvalidation

Stop Using any: A Type Guard Primer

Your linter is right to complain about any. Every any in your codebase is a hole in the type system -- a place where TypeScript stops helping you and runtime bugs start hiding.

The fix isn't complicated. It's a shift in thinking.


The Problem with any

When you write any, you're telling the compiler: "Trust me, I know what this is."

Except you usually don't. Not at boundaries.

the-lie.ts(typescript)
1// This compiles. It also crashes at runtime.
2class="text-category-tech font-medium">function loadConfig(raw: any) {
3 class="text-category-tech font-medium">return raw.plugins.map((p: any) => p.name.toUpperCase());
4}
5
6// raw could be null, a string, a number...
7// raw.plugins could be undefined
8// p.name could be missing

Every property access on any is an unchecked assumption. Chain three of them and you've written code that's more fragile than plain JavaScript -- because you've told everyone (including yourself) that the types are handled.

They're not.


Use unknown at Boundaries

unknown is the type-safe counterpart to any. It means: "I don't know what this is yet, and TypeScript won't let me pretend I do."

Use unknown when:

    1. Data comes from JSON (config files, network responses, filesystem reads)
    2. Plugin configuration is arbitrary and defined by third parties
    3. Function arguments arrive from external callers you don't control
    4. Deserialized data from localStorage, databases, or message queues
honest-boundary.ts(typescript)
1// This won't compile until you narrow the type
2class="text-category-tech font-medium">function loadConfig(raw: unknown) {
3 // raw.plugins <-- Error: Object is of type 'unknown'
4 // You MUST validate first
5}

That compiler error is a feature. It forces you to prove the shape of the data before you use it.


Never "Use" unknown Directly

The key insight: unknown is a gate, not a destination. You never operate on unknown values. You narrow them first through one of two strategies:

  • Validate with a schema (Zod, TypeBox, AJV, Valibot)
  • Write a tight type guard
  • Both work. Choose based on complexity.

    Type narrowing: from unknown chaos to validated structure

    Type narrowing: from unknown chaos to validated structure


    The isRecord Guard

    This single function eliminates an entire class of bugs:

    is-record.ts(typescript)
    1class="text-category-tech font-medium">function isRecord(v: unknown): v is Record {
    2 class="text-category-tech font-medium">return typeof v === class="text-category-music">'object' && v !== null && !Array.isArray(v);
    3}

    Three checks. That's all it takes:

    • typeof v === 'object' -- eliminates primitives (strings, numbers, booleans)
    • v !== null -- eliminates null (which is typeof 'object' in JavaScript's oldest bug)
    • !Array.isArray(v) -- eliminates arrays (which are also typeof 'object')

    What's left? An actual object with string keys. Now you can safely access properties.


    Composing Guards

    Once you have isRecord, you build upward:

    composed-guards.ts(typescript)
    1class="text-category-tech font-medium">function isRecord(v: unknown): v is Record {
    2 class="text-category-tech font-medium">return typeof v === class="text-category-music">'object' && v !== null && !Array.isArray(v);
    3}
    4
    5class="text-category-tech font-medium">function isPluginConfig(v: unknown): v is { name: string; options: Record } {
    6 class="text-category-tech font-medium">if (!isRecord(v)) class="text-category-tech font-medium">return false;
    7 class="text-category-tech font-medium">if (typeof v.name !== class="text-category-music">'string') class="text-category-tech font-medium">return false;
    8 class="text-category-tech font-medium">if (class="text-category-music">'options' in v && !isRecord(v.options)) class="text-category-tech font-medium">return false;
    9 class="text-category-tech font-medium">return true;
    10}
    11
    12class="text-category-tech font-medium">function loadConfig(raw: unknown) {
    13 class="text-category-tech font-medium">if (!isRecord(raw)) {
    14 throw new Error(class="text-category-music">'Config must be an object');
    15 }
    16
    17 class="text-category-tech font-medium">if (!Array.isArray(raw.plugins)) {
    18 throw new Error(class="text-category-music">'Config must have a plugins array');
    19 }
    20
    21 class="text-category-tech font-medium">const plugins = raw.plugins.filter(isPluginConfig);
    22
    23 class="text-category-tech font-medium">return plugins.map(p => p.name.toUpperCase()); // fully typed, fully safe
    24}

    No any. No unsafe casts. TypeScript narrows the type at each step and every property access is guaranteed.


    Schema Validation for Complex Shapes

    When structures get deep, a schema library scales better than hand-rolled guards:

    with-zod.ts(typescript)
    1import { z } from class="text-category-music">'zod';
    2
    3class="text-category-tech font-medium">const PluginSchema = z.object({
    4 name: z.string(),
    5 options: z.record(z.unknown()).optional(),
    6});
    7
    8class="text-category-tech font-medium">const ConfigSchema = z.object({
    9 plugins: z.array(PluginSchema),
    10 output: z.string().default(class="text-category-music">'./dist'),
    11 verbose: z.boolean().default(false),
    12});
    13
    14type Config = z.infer;
    15
    16class="text-category-tech font-medium">function loadConfig(raw: unknown): Config {
    17 class="text-category-tech font-medium">return ConfigSchema.parse(raw);
    18 // Throws ZodError with detailed messages if invalid
    19 // Returns fully typed Config if valid
    20}

    Same principle. The input is unknown. The output is fully typed. The boundary is explicit.


    When to Use Which

    • Type guards for shallow checks, hot paths, and zero-dependency contexts
    • Schema validation for complex nested structures, user input, API responses, and config files
    • Both together when you want fast guards for common cases and thorough validation for the rest

    Rule of Thumb

    If you're checking more than 3 properties, reach for a schema library. If it's a single shape check, a type guard is cleaner.


    The any Escape Hatches (When They're Legitimate)

    There are rare cases where any is defensible:

    • Generic library internals where you're building type utilities and the outer API is fully typed
    • Third-party type workarounds when a library's types are wrong and a PR is pending
    • Gradual migration from JavaScript where you're adding types incrementally

    Even then, contain it. Use // eslint-disable-next-line @typescript-eslint/no-explicit-any with a comment explaining why.

    contained-escape.ts(typescript)
    1// TEMPORARY: lib@3.2.0 types are wrong for this overload
    2// PR: https://github.com/example/lib/pull/1234
    3// eslint-disable-next-line @typescript-eslint/no-explicit-any
    4class="text-category-tech font-medium">const result = lib.transform(input as any);

    Document it. Link the issue. Revisit it.


    The Typing Rule

    At every boundary where data enters your system -- network, filesystem, user input, config, plugins -- the type is unknown. Narrow it explicitly with a guard or a schema. Never cast through any to skip the work.

    Every any you remove is a runtime crash you'll never have to debug. Start with isRecord. Build from there.