DEV Community

Cover image for TypeScript Fundamentals, Part 2: Writing Safer Code
Grant Riordan
Grant Riordan

Posted on

TypeScript Fundamentals, Part 2: Writing Safer Code

📚 TypeScript Fundamentals series

Each part stands on its own. Read them in order, or jump straight to the one you need.

TypeScript only helps you if you let it. You can write code that looks typed but quietly opts out of the safety net, and the compiler won't stop you. This part is about the habits and settings that make TypeScript actually catch bugs.

Who is this part for? Anyone who has written some TypeScript, or who knows what an interface and a union type are. If you haven't, here's all you need:

Quick refresher. An interface or type describes the shape of an object. A union like string | number means "one of these types". A literal type like 'idle' means exactly that one value. That's it; the rest is explained below. (Part 1 goes deeper if you want it.)

Type Inference: Let TypeScript Do the Work

You don't need to annotate everything. TypeScript works out types from the values you give it:

let count = 5;            // inferred as number
let title = "Hello";      // inferred as string
const isDone = false;     // inferred as the literal type false

count = "five";           // ❌ Type 'string' is not assignable to type 'number'
Enter fullscreen mode Exit fullscreen mode

Notice that let and const infer differently. A let variable can change, so TypeScript widens its type to number. A const can never change, so TypeScript keeps the narrowest type it can:

const maxRetries = 3;     // type is the literal 3
let retries = 3;          // type is number
Enter fullscreen mode Exit fullscreen mode

Inference works for function return types too:

function double(n: number) {
    return n * 2;         // return type inferred as number
}
Enter fullscreen mode Exit fullscreen mode

When to annotate

A good rule of thumb: annotate the boundaries, infer the middle.

  • Function parameters. TypeScript can't infer these, so you always annotate them.

  • Exported or public functions' return types. This documents the contract and catches accidental changes.

  • Variables that start empty. TypeScript can't guess what will go in later.

const names: string[] = [];          // without the annotation, this is hard to use
const lookup: Record<string, number> = {};

function getUserName(id: number): string {   // parameter + return type annotated
    // ...
    return "Sam";
}
Enter fullscreen mode Exit fullscreen mode

For everything else, such as local variables and intermediate values, leave the annotations off. Redundant annotations add noise without adding safety.

Strict Mode and tsconfig.json

Most of TypeScript's safety comes from one setting. Every TypeScript project has a tsconfig.json file (generate one with npx tsc --init), and the most important line in it is:

{
  "compilerOptions": {
    "strict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

strict is a bundle of checks. Two of them do most of the heavy lifting:

  • noImplicitAny: stops TypeScript from quietly giving a parameter the type any when you forget to annotate it.
  • strictNullChecks: makes null and undefined distinct types instead of values that can sneak into anything.

Here's strictNullChecks in action:

function shout(message: string | null) {
    return message.toUpperCase();   // ❌ 'message' is possibly 'null'
}

function shoutSafely(message: string | null) {
    if (message === null) {
        return "";
    }
    return message.toUpperCase();   // ✅ TypeScript knows it's a string here
}
Enter fullscreen mode Exit fullscreen mode

Without strict mode, the first version compiles happily and crashes at runtime with the classic "cannot read properties of null" error. With it, the compiler forces you to deal with the missing case up front.

Start new projects with "strict": true. Turning it on later in an existing codebase can produce a lot of errors at once, so many teams enable the individual flags one at a time.

any, unknown and never

These three special types look similar but do very different jobs.

any: switching the checker off

any means "anything goes, don't check this". It's contagious and it defeats the purpose of using TypeScript:

let value: any = "hello";
value.doesNotExist();     // ✅ compiles. -- crashes at runtime
value = 42;
value.toUpperCase();      // ✅ compiles. -- crashes at runtime
Enter fullscreen mode Exit fullscreen mode

Treat any as an escape hatch of last resort.

unknown: the safe alternative

unknown also means "could be anything", but with a crucial difference: you must check what it is before you use it.

let value: unknown = "hello";

value.toUpperCase();              // ❌ 'value' is of type 'unknown'

if (typeof value === "string") {
    value.toUpperCase();          // ✅ narrowed to string
}
Enter fullscreen mode Exit fullscreen mode

unknown is the right type for data you can't trust yet, such as parsed JSON, user input or API responses. Be aware that JSON.parse returns any, so annotating the result as unknown is a good habit:

const parsed: unknown = JSON.parse(text);   // now you must check it before using it
Enter fullscreen mode Exit fullscreen mode

never: the type with no values

never represents something that can't happen: a function that always throws, or a branch the compiler knows is unreachable. That makes it very useful for exhaustiveness checking with unions:

type Shape =
    | { kind: 'circle'; radius: number }
    | { kind: 'square'; size: number };

function assertNever(x: never): never {
    throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function area(shape: Shape): number {
    switch (shape.kind) {
        case 'circle': return Math.PI * shape.radius ** 2;
        case 'square': return shape.size ** 2;
        default:       return assertNever(shape);  // compile error if a case is missing
    }
}
Enter fullscreen mode Exit fullscreen mode

If you later add | { kind: 'triangle'; ... } to Shape, the assertNever(shape) line stops compiling until you handle the new case. Instead of a silent bug, you get a compile error pointing at exactly what to fix.

Narrowing and Type Guards

Narrowing is how TypeScript refines a broad type into a more specific one inside a block of code. You've already seen it with typeof above. Here are the main tools.

typeof: for primitives

function format(value: string | number) {
    if (typeof value === "string") {
        return value.toUpperCase();   // string
    }
    return value.toFixed(2);          // number
}
Enter fullscreen mode Exit fullscreen mode

instanceof: for classes

function describe(err: Error | string) {
    if (err instanceof Error) {
        return err.message;           // Error
    }
    return err;                       // string
}
Enter fullscreen mode Exit fullscreen mode

in: for checking a property exists

interface Dog { bark(): void }
interface Cat { meow(): void }

function speak(pet: Dog | Cat) {
    if ("bark" in pet) {
        pet.bark();                   // Dog
    } else {
        pet.meow();                   // Cat
    }
}
Enter fullscreen mode Exit fullscreen mode

Equality and truthiness

function greet(name?: string) {
    if (name) {
        return `Hello, ${name}`;      // string (non-empty)
    }
    return "Hello, stranger";
}
Enter fullscreen mode Exit fullscreen mode

Discriminated unions

If your union members share a literal property (like kind or status), checking that property narrows the whole type. We used this in area() above.

Custom type guards

For anything more complex, write your own guard. The special return type x is User tells TypeScript that if the function returns true, the argument is a User:

interface User {
    id: number;
    name: string;
}

function isUser(value: unknown): value is User {
    return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        "name" in value
    );
}

const data: unknown = JSON.parse('{"id": 1, "name": "Sam"}');

if (isUser(data)) {
    console.log(data.name);           // ✅ data is a User here
}
Enter fullscreen mode Exit fullscreen mode

One honest caveat: TypeScript trusts your guard. The version above only checks that the properties exist, not that they have the right types. A more thorough guard would also check typeof value.id === "number".

Escape Hatches: as and !

Sometimes you know more than the compiler does. TypeScript gives you two ways to override it:

// Type assertion: "trust me, this is a string"
const input = document.getElementById("name") as HTMLInputElement;

// Non-null assertion: "trust me, this isn't null"
const el = document.getElementById("app")!;
Enter fullscreen mode Exit fullscreen mode

These are sometimes necessary, but they are promises, not checks. If you're wrong, nothing stops the crash at runtime. Prefer narrowing whenever you can.

Types disappear at runtime

This is the most important thing to understand about TypeScript. Types are erased when your code is compiled to JavaScript. They help you while writing code, but they don't exist when it runs, so they can't validate data that arrives from outside your program:

interface User { id: number; name: string }

const res = await fetch("/api/users");
const users = (await res.json()) as User[];   // ⚠️ this checks nothing
Enter fullscreen mode Exit fullscreen mode

If the API returns something different, TypeScript has no idea. For data from APIs, files or user input, validate it at runtime, either with a hand-written type guard like isUser above or with a validation library such as Zod, which lets you define a schema once and derive the TypeScript type from it.

Wrapping Up

  • Let inference work. Annotate function parameters and public return types; leave the rest.

  • Turn on "strict": true. It's where most of TypeScript's safety comes from, especially strictNullChecks.

  • Avoid any. Use unknown for untrusted data and narrow it before use.

  • Use never for exhaustiveness checks so the compiler tells you when you've missed a case.

  • Narrow with typeof, instanceof, in, discriminants and custom type guards.

  • Remember that types vanish at runtime. Assertions are promises; validate external data.

Continue the Series

Top comments (0)