What is TypeScript? Why I Finally Stopped Fighting the Type System

TypeScript is JavaScript with types. That's the short answer. The real answer involves a friend who was right about something I didn't want to hear, a production bug that types would have caught immediately, and the slow realization that the compiler was never the enemy.

A friend of mine had been on my case about TypeScript since around 2018. Every time I showed him JavaScript code, he'd go through the same routine: "Why aren't you using TypeScript? Just use TypeScript." And every time, I had the same answer: "I don't need the compiler telling me what to do. I know what my variables are."

I was wrong. I knew I was wrong the moment a production Discord bot crashed because a function returned undefined when I expected an object, and I spent two hours hunting down which property access on a null reference was the culprit. That bug would not have made it past TypeScript's compiler. The type checker would have flagged it in my editor before I even ran the code.

I converted the bot to TypeScript that week. I haven't shipped a serious JavaScript project without it since.

What TypeScript Is

TypeScript is a superset of JavaScript developed by Microsoft, first released in 2012. Every valid JavaScript file is valid TypeScript. The language adds optional static type annotations, a type inference engine, and a compiler that strips those types and outputs standard JavaScript.

The output is JavaScript. TypeScript never runs in the browser or in Node.js directly. The compiler (tsc) or a bundler like esbuild processes your TypeScript and produces the JavaScript that actually executes.

This matters for one reason: TypeScript's types exist only at compile time. Once the code runs, they're gone. There's no runtime type checking. The type system is a development-time safety net, not a runtime enforcement mechanism.

The Type System

Basic Types

TypeScript's primitive types map directly to JavaScript's: string, number, boolean, null, undefined, symbol, bigint. You annotate variables, function parameters, and return values with these types.

let name: string = "Traven";
let age: number = 30;
let active: boolean = true;

function greet(name: string): string {
  return `Hello, ${name}`;
}

TypeScript can often infer the type without the annotation:

let name = "Traven"; // TypeScript knows this is a string

The annotation is optional when TypeScript can figure it out. Explicit annotations are most useful in function signatures and complex object shapes where the inference would be ambiguous.

Arrays and Objects

Arrays take a type parameter for their elements. Object shapes are defined inline or through interfaces.

const scores: number[] = [95, 87, 92];

// Inline object type
const user: { name: string; age: number } = {
  name: "Traven",
  age: 30
};

Interfaces

Interfaces define the shape of an object. They're reusable and composable.

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}

function sendWelcomeEmail(user: User): void {
  console.log(`Sending to ${user.email}`);
}

The "admin" | "editor" | "viewer" is a union type. The role property must be one of those three exact strings. TypeScript will reject anything else at compile time.

Type Aliases

type creates an alias for any type, including unions, intersections, and complex generics.

type UserId = number;
type UserRole = "admin" | "editor" | "viewer";

type ApiResponse<T> = {
  data: T;
  status: number;
  error: string | null;
};

const response: ApiResponse<User[]> = {
  data: [],
  status: 200,
  error: null
};

ApiResponse<T> is a generic type. The T is a type parameter, filled in when you use the type. ApiResponse<User[]> means the data field will be an array of User objects.

Interfaces vs Types

Both interface and type define object shapes. The practical difference: interfaces can be extended with extends and merged through declaration merging. Types are more flexible for non-object type aliases (unions, intersections, mapped types).

For object shapes, use interfaces. For everything else, use types. That's not a hard rule, but it's a reasonable default that most TypeScript codebases follow.

Optional and Readonly Properties

interface Config {
  host: string;
  port: number;
  timeout?: number;      // optional
  readonly apiKey: string; // can't be reassigned after creation
}

Generics

Generics let you write functions and classes that work with any type while preserving type information.

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const firstNumber = first([1, 2, 3]); // TypeScript knows this is number | undefined
const firstString = first(["a", "b"]); // TypeScript knows this is string | undefined

Without generics, you'd have to use any and lose all type safety, or write separate functions for every type you wanted to support.

The unknown Type

unknown is the type-safe alternative to any. A value of type unknown can hold anything, but TypeScript won't let you do anything with it until you narrow the type.

function processInput(input: unknown) {
  if (typeof input === "string") {
    console.log(input.toUpperCase()); // TypeScript knows it's a string here
  } else if (typeof input === "number") {
    console.log(input.toFixed(2)); // TypeScript knows it's a number here
  }
}

Use unknown when you receive data from an external source (API response, user input, JSON.parse) and want to handle it safely.

The tsconfig.json File

The tsconfig.json file at the root of your project controls compiler behavior. The most important setting is strict.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

"strict": true enables a bundle of strict type-checking options. The most impactful are strictNullChecks (which makes null and undefined their own types instead of assignable to everything) and noImplicitAny (which forces you to annotate when TypeScript can't infer a type).

Run TypeScript without strict: true and you're leaving most of the protection on the table.

Real-World Usage

Typing API Responses

The biggest practical win from TypeScript in production code is typing external data.

interface GitHubUser {
  login: string;
  id: number;
  avatar_url: string;
  name: string | null;
  email: string | null;
  public_repos: number;
}

async function fetchUser(username: string): Promise<GitHubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as GitHubUser;
}

The as GitHubUser cast tells TypeScript to treat the parsed JSON as a GitHubUser. TypeScript trusts you here, so you're responsible for making sure the interface matches the actual API response. If you want runtime validation too, use a library like Zod alongside the TypeScript types.

Discriminated Unions

Discriminated unions are one of TypeScript's most powerful patterns. You use a shared property (the discriminant) to narrow which member of a union you're dealing with.

type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function handleResult(result: ApiResult<User>) {
  if (result.success) {
    // TypeScript knows result.data exists here
    console.log(result.data.name);
  } else {
    // TypeScript knows result.error exists here
    console.log(result.error);
  }
}

This pattern eliminates the data?.name optional chaining dance and makes impossible states unrepresentable.

Classes with TypeScript

TypeScript adds visibility modifiers to classes: public, private, protected, and readonly.

class DatabaseConnection {
  private readonly connectionString: string;
  private connected: boolean = false;

  constructor(connectionString: string) {
    this.connectionString = connectionString;
  }

  async connect(): Promise<void> {
    // connect logic
    this.connected = true;
  }

  get isConnected(): boolean {
    return this.connected;
  }
}

The private keyword is enforced at compile time. For runtime enforcement, use JavaScript's native #privateField syntax, which TypeScript also supports.

Gradual Adoption

You don't have to convert an entire codebase at once. TypeScript supports gradual adoption through the allowJs compiler option, which lets you mix .ts and .js files in the same project. You can rename files from .js to .ts one at a time, fixing type errors as you go.

The @ts-check comment at the top of a .js file enables basic type checking through JSDoc comments without converting to TypeScript:

// @ts-check

/**
 * @param {string} name
 * @returns {string}
 */
function greet(name) {
  return `Hello, ${name}`;
}

This works, but it's verbose compared to actual TypeScript syntax. Use it as a stepping stone, not a destination.

The Build Step

TypeScript requires compilation. In a Node.js project, you're running tsc or using a bundler with TypeScript support. In a browser project, your bundler (Vite, Webpack, esbuild) handles the TypeScript compilation as part of the build.

For development speed, tools like ts-node and tsx let you execute TypeScript directly without an explicit compile step. tsx is faster and handles ESM correctly.

Deno and Bun both execute TypeScript natively without a separate compilation step. If you're on one of those runtimes, the build overhead disappears. More on that in the What is Deno and What is Bun articles.

Where TypeScript Doesn't Help

TypeScript's types are erased at runtime. JSON.parse() returns any. External API responses don't match your interface definitions at runtime unless you validate them explicitly. Type assertions (as SomeType) trust you completely.

The type system can't guarantee that runtime values match your compile-time types. You bridge that gap with runtime validation — Zod is the current standard, and it generates TypeScript types from schemas, keeping your compile-time and runtime validation in sync.

TypeScript also adds setup friction to small scripts. A ten-line utility script probably doesn't need a tsconfig.json. For quick work, JavaScript is fine. For anything you're shipping, TypeScript pays for itself quickly.

Compared to JavaScript

If you want the full comparison between JavaScript and TypeScript, including when each is the right choice, that's in the JavaScript vs TypeScript article.

The short version: TypeScript is JavaScript with a safety net. The net costs you a build step and some upfront annotation work. In exchange, your editor catches bugs before you run anything, refactoring is safer, and reading unfamiliar code takes less time because the types document intent.

My friend was right. I was stubborn. Use TypeScript.

Where to Go From Here

TypeScript builds on JavaScript. If your JavaScript fundamentals aren't solid, especially the async model, start with the JavaScript article and the event loop deep dive.

For practical TypeScript in a real project, the Discord bot guide uses Node.js, TypeScript, and the Eris library to build something functional from scratch. That's where I'd start if you want to see TypeScript in a working application rather than isolated examples.