TypeScript in Practice: Types, Generics and Project Setup

TypeScript is a statically typed superset of JavaScript: it type-checks your program before it runs, catching the most common class of errors — using a value of one kind where another kind was expected. After years of growth, TypeScript has become the de facto standard for modern frontend engineering; the default scaffolds of React, Vue, and Next.js all ship with built-in support.

TypeScript is a core language skill under the frontend building category on 16IDC. For a broader tooling view, see the frontend toolchain 2026; to apply it in React or Vue, read the React 19 guide or the Vue 3 Composition API guide alongside.

Why Types Matter

As JavaScript scales, "what a function returns" and "which fields an object has" are only discoverable through runtime errors. The point of a type system is less about eliminating all bugs and more about moving errors earlier — from "after launch" to "while editing". The red squiggles in your editor, Ctrl+click to jump to a definition, and cross-file reference updates during refactors are all dividends of having type information. TypeScript doesn't change runtime behavior; it still compiles to standard JavaScript. In other words, types are a pure development-time asset, and how well you write them directly shapes your daily experience.

Basic Types and Narrowing

Beyond string, number, and boolean, common compositions include arrays T[], tuples [string, number], unions A | B, optional fields ?, and literal types such as "success". Combined with typeof/in/instanceof checks and type guards, you can narrow wide types into concrete ones at runtime — a key skill for robust code.

Take a function that accepts either a string or an object carrying a data field:

type Response =
  | { status: "error"; message: string }
  | { status: "ok"; data: User }

function handle(res: Response) {
  if (res.status === "error") {
    // narrowed here: res only has message
    console.error(res.message)
  } else {
    // res only has data, typed as User
    res.data.id
  }
}

Equality narrowing over a discriminated union is the most common trick: use a discriminant field like status to split the union, and TypeScript infers the remaining fields in each branch. Once you get the hang of it, "null pointer"-style mistakes largely disappear.

Generics: Reusable Type Components

Generics let functions, interfaces, and classes work across many types without losing type information. Take the identity function:

function identity<Type>(arg: Type): Type {
  return arg
}

let output = identity<string>("hello")

Constraints (extends) limit the shape of type parameters:

interface Lengthwise {
  length: number
}

function logLength<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length)
  return arg
}

Combined with keyof, conditional types, and mapped types, you can express complex relationships like key-to-value mappings with very little boilerplate. The generic you'll reach for most in real work is a shared request helper: fetchList<T>(url): Promise<T[]>. Call it as fetchList<User>("/api/users") and the return value is automatically typed as User[] — when the backend contract changes, the frontend finds out at compile time instead of at runtime.

Project Configuration: tsconfig.json

A well-tuned tsconfig.json makes type checking real. Recommended key options:

Option Purpose
strict Enables all strict checks; teams should default to on
target / module Compile target and module format
moduleResolution Module resolution strategy (bundler suits modern tools like Vite)
paths Path aliases such as @/*
noUncheckedIndexedAccess Warns when indexed access may be undefined

@ts-check also brings checking to plain JS files for gradual migration. strict is the single switch worth enabling from day one: it turns on a whole family of checks including strictNullChecks and noImplicitAny. Migrating old code will surface a pile of errors, but a greenfield project has no legacy baggage — just turn it on.

Working with Build Tools

TypeScript itself only checks types and transpiles; the actual bundling is left to build tools. With Vite, esbuild/oxc handles transpilation and tsc --noEmit handles type checking — a clean separation of concerns (see the Vite build tool guide). Many teams add a dedicated tsc --noEmit step to CI as a gate: code has to pass type checking before it merges, which beats relying on every developer's local discipline. It also helps to enable verbatimModuleSyntax in tsconfig so pure types are imported with import type; esbuild can then strip those imports outright, avoiding "importing a value that doesn't exist at runtime" bugs.

Adoption Advice

  1. Enable strict by default: avoid any creep from day one.
  2. Type your public APIs: component props and API response shapes give the highest return.
  3. Be disciplined with any: prefer unknown plus narrowing.
  4. Decouple types from runtime: use interface/type for data contracts and runtime validation libraries for external input.

Frequently Asked Questions

  • What's the difference between any and unknown? any drops all checking; unknown forces you to narrow before using it. Prefer unknown.
  • interface or type? Use interface for object contracts (it merges and extends), and type for unions, intersections, and other compositions.
  • A third-party library has no types? Install @types/xxx first; if none exists, declare a module with declare module — but don't just mark the whole library as any.
  • Compilation too slow? Use tsc --noEmit purely for checking and let esbuild/oxc bundle; on large projects enable incremental.
  • Too many errors after turning on strict? Fix files one by one, using // @ts-expect-error to keep the pipeline green while you clean up module by module — but don't disable strict globally just to pass checks.

Source: https://www.typescriptlang.org/docs/handbook/intro.html
Reference: https://www.typescriptlang.org/tsconfig/ (full tsconfig reference)