Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 9 min read

TypeScript for Frontend Developers: Types That Catch Bugs Before Users Do

A practical introduction to TypeScript in frontend projects — type annotations, interfaces, generics, and the compiler settings that make React and API code safer without slowing you down.

By codefunc

typescriptjavascripttypesreactfrontenddx

JavaScript runs in browsers. TypeScript runs in your editor — catching typos, wrong argument types, and missing properties before you refresh the page. The tradeoff is explicit: you write more annotations, and the compiler tells you when your code contradicts itself. For frontend projects with API responses, component props, and state management, that tradeoff pays off fast.

Bottom line: TypeScript adds a type layer on top of JavaScript. You define shapes — interfaces, types, enums — and the compiler checks your code against them. Start with strict mode on new projects. Add types to existing JavaScript incrementally. Let the tooling catch bugs; let you focus on features.

Why TypeScript for frontend

Benefit How it helps
Autocomplete Editor knows every property on your objects
Refactoring Rename a field, find every usage instantly
API contracts Response types catch backend changes early
Prop validation Component props documented and enforced
Team onboarding Types are documentation that never drifts
Fewer runtime errors Catch undefined access before production

TypeScript does not change runtime behavior. It compiles to JavaScript. The types disappear — but the bugs they prevented stay gone.

Setting up TypeScript

New project

npm create vite@latest my-app -- --template react-ts
# or
npx create-next-app@latest --typescript

Modern scaffolds default to TypeScript.

Existing JavaScript project

npm install -D typescript @types/react @types/react-dom
npx tsc --init

Rename files from .js to .ts (or .jsx to .tsx for React). Fix errors incrementally.

tsconfig.json essentials

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

Start strict. Relaxing later is easy; tightening an existing codebase is painful.

Type basics

Primitives

const name: string = "JSON Formatter";
const count: number = 72;
const isActive: boolean = true;
const nothing: null = null;
const missing: undefined = undefined;

TypeScript infers types from values — explicit annotations are often unnecessary:

const name = "JSON Formatter"; // inferred as string

Arrays

const tools: string[] = ["json-formatter", "jwt-decoder"];
const counts: Array<number> = [1, 2, 3]; // equivalent

Objects

const tool: { name: string; slug: string } = {
  name: "JSON Formatter",
  slug: "json-formatter",
};

For reusable shapes, use interfaces or type aliases.

Interfaces vs type aliases

Both define object shapes. The difference is subtle:

// Interface — extendable, mergeable
interface Tool {
  name: string;
  slug: string;
}

interface Tool {
  category: string; // declaration merging: adds to existing Tool
}

// Type alias — more flexible, not mergeable
type ToolType = {
  name: string;
  slug: string;
};

type Category = "formatters" | "encoders" | "converters";

When to use each

Use Recommendation
Object shapes Interface (convention in React ecosystem)
Union types Type alias (type Status = "idle" | "loading" | "error")
Function types Either works; type alias often cleaner
Extending shapes Interface with extends
Library definitions Interface (allows consumer extension)

In practice, pick one convention per project and stay consistent.

Functions

Parameter and return types

function formatJson(input: string, indent: number = 2): string {
  return JSON.stringify(JSON.parse(input), null, indent);
}

// Arrow function
const formatJson = (input: string, indent = 2): string => {
  return JSON.stringify(JSON.parse(input), null, indent);
};

Function type signatures

type Formatter = (input: string) => string;

const uppercase: Formatter = (input) => input.toUpperCase();

Optional and default parameters

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

greet("Ada");           // "Hello, Ada"
greet("Ada", "Hi");     // "Hi, Ada"

Union types

A value that can be one of several types:

type Result = 
  | { ok: true; data: string }
  | { ok: false; error: string };

function handleResult(result: Result) {
  if (result.ok) {
    console.log(result.data);  // TypeScript knows data exists
  } else {
    console.error(result.error); // TypeScript knows error exists
  }
}

Discriminated unions

The ok property acts as a discriminant — TypeScript narrows the type based on its value.

type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "error"; message: string }
  | { status: "loading" };

function render(response: ApiResponse<User>) {
  switch (response.status) {
    case "success":
      return <UserCard user={response.data} />;
    case "error":
      return <Error message={response.message} />;
    case "loading":
      return <Spinner />;
  }
}

Generics

Generics let you write reusable code that works with multiple types:

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

const tool = first(["json-formatter", "jwt-decoder"]); // string | undefined
const count = first([1, 2, 3]); // number | undefined

Generic interfaces

interface ApiResponse<T> {
  data: T;
  timestamp: number;
}

type ToolResponse = ApiResponse<Tool>;
type UserResponse = ApiResponse<User>;

Generic constraints

interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}

TypeScript with React

Component props

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary";
  disabled?: boolean;
}

function Button({ label, onClick, variant = "primary", disabled }: ButtonProps) {
  return (
    <button 
      onClick={onClick} 
      disabled={disabled}
      className={variant}
    >
      {label}
    </button>
  );
}

Children prop

interface CardProps {
  title: string;
  children: React.ReactNode;
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      {children}
    </div>
  );
}

Event handlers

function Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // ...
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
    </form>
  );
}

useState with types

// Inferred from initial value
const [count, setCount] = useState(0); // number

// Explicit type for complex state
const [user, setUser] = useState<User | null>(null);

// Union state
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");

useRef

// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);

// Mutable value ref
const countRef = useRef<number>(0);

API response types

Defining response shapes

interface User {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}

interface ApiError {
  code: string;
  message: string;
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error("Failed to fetch user");
  }
  return response.json();
}

Generating types from JSON

When integrating a new API, paste a sample response into the JSON to TypeScript converter to bootstrap your interface. Review and refine — inferred types from one response may miss optional fields or union possibilities.

Validate API response samples with the JSON Validator before generating types.

Zod for runtime validation

TypeScript types disappear at runtime. For API responses, consider runtime validation:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return UserSchema.parse(data); // throws if invalid
}

Utility types

TypeScript includes built-in types for common transformations:

Utility Effect
Partial<T> All properties optional
Required<T> All properties required
Readonly<T> All properties readonly
Pick<T, K> Subset of properties
Omit<T, K> Exclude properties
Record<K, V> Object with keys K and values V

Examples

interface Tool {
  name: string;
  slug: string;
  description: string;
  category: string;
}

// For update forms — all fields optional
type ToolUpdate = Partial<Tool>;

// Just the identifiers
type ToolRef = Pick<Tool, "name" | "slug">;

// Everything except category
type ToolWithoutCategory = Omit<Tool, "category">;

// Lookup table
type ToolsBySlug = Record<string, Tool>;

Common patterns

Exhaustive switch

type Status = "idle" | "loading" | "success" | "error";

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

function getStatusMessage(status: Status): string {
  switch (status) {
    case "idle": return "Ready";
    case "loading": return "Loading...";
    case "success": return "Done!";
    case "error": return "Failed";
    default: return assertNever(status); // compile error if case missing
  }
}

Type guards

interface Dog {
  kind: "dog";
  bark: () => void;
}

interface Cat {
  kind: "cat";
  meow: () => void;
}

type Pet = Dog | Cat;

function isDog(pet: Pet): pet is Dog {
  return pet.kind === "dog";
}

function handlePet(pet: Pet) {
  if (isDog(pet)) {
    pet.bark(); // TypeScript knows it's a Dog
  } else {
    pet.meow(); // TypeScript knows it's a Cat
  }
}

as const for literal types

const CATEGORIES = ["formatters", "encoders", "converters"] as const;
type Category = typeof CATEGORIES[number]; // "formatters" | "encoders" | "converters"

Strict mode flags

Enable these in tsconfig.json for maximum safety:

Flag Effect
strict Enables all strict checks
noUncheckedIndexedAccess Array/object index access returns T | undefined
exactOptionalPropertyTypes Distinguishes missing from undefined
noImplicitAny Errors on implicit any
strictNullChecks null and undefined are distinct types

noUncheckedIndexedAccess example

const tools = ["json-formatter", "jwt-decoder"];

// Without flag: string
// With flag: string | undefined
const first = tools[0];

// Forces you to handle undefined
if (first) {
  console.log(first.toUpperCase());
}

Migration tips

From JavaScript to TypeScript

  1. Rename files.js.ts, .jsx.tsx
  2. Start with strict: false — fix errors incrementally
  3. Add types to function boundaries — parameters and returns
  4. Use unknown over any — forces type narrowing
  5. Generate types from JSON — bootstrap API response interfaces
  6. Enable strict flags one by one — commit after each

Escape hatches

When fighting the compiler, you have options:

// Type assertion (you know better)
const element = document.getElementById("root") as HTMLDivElement;

// Non-null assertion (you're sure it exists)
const element = document.getElementById("root")!;

// any (last resort)
const data: any = unknownThirdPartyLib.getData();

Use sparingly. Each escape hatch is a place where TypeScript can't help you.

Practical takeaway

TypeScript catches errors at compile time that JavaScript catches at runtime (or doesn't catch at all). Start new projects with strict mode. Define interfaces for component props and API responses. Use union types for state machines. Generate initial types from JSON samples, then refine. The investment in type annotations pays back in autocomplete, refactoring confidence, and bugs caught before deployment.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool