MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Introduction to TypeScript: Getting Started Guide

TypeScript basics: types, interfaces, generics, and configuring tsconfig.json.

TypeScriptGetting StartedJavaScriptTypes

By MinhVo

Introduction

TypeScript has become the industry standard for building reliable, maintainable JavaScript applications. Developed by Microsoft and first released in 2012, TypeScript adds optional static typing to JavaScript, catching type errors at compile time rather than runtime. Major frameworks like Angular, React, Vue, and Next.js all provide first-class TypeScript support, making it an essential skill for modern web developers working on production applications.

This comprehensive guide covers TypeScript from foundational concepts to advanced patterns. You'll master basic types, interfaces, generics, utility types, and configuration options. We'll explore real-world implementation patterns, testing strategies, and performance considerations that help you write production-grade TypeScript code.

TypeScript Development Environment

Understanding TypeScript: Core Concepts

Why TypeScript Matters

JavaScript's dynamic typing provides flexibility but creates challenges in large codebases. Type errors only surface at runtime, making refactoring risky and collaboration difficult. TypeScript solves these problems by providing compile-time type checking, excellent IDE support through language services, and self-documenting code through type annotations.

Companies like Microsoft, Google, Airbnb, and Slack use TypeScript extensively. The language reduces production bugs by 15-20% according to multiple industry studies, making it a worthwhile investment for team productivity.

Type System Fundamentals

TypeScript provides several built-in types covering common use cases:

// Primitive types
let name: string = "John";
let age: number = 30;
let isActive: boolean = true;
let nothing: null = null;
let undefinedValue: undefined = undefined;
let id: symbol = Symbol("id");
let bigNumber: bigint = 9007199254740991n;
 
// Array types
let numbers: number[] = [1, 2, 3, 4, 5];
let names: Array<string> = ["Alice", "Bob", "Charlie"];
 
// Tuple types
let user: [string, number] = ["John", 30];
let rgb: [number, number, number] = [255, 128, 0];
 
// Enum types
enum Status {
    Active = "ACTIVE",
    Inactive = "INACTIVE",
    Pending = "PENDING",
    Suspended = "SUSPENDED",
}
 
// Union types
type StringOrNumber = string | number;
type Result = "success" | "error" | "loading";
 
// Literal types
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

Type Assertions and Guards

Type assertions tell the compiler you know more about a type:

// Type assertions
const input = document.getElementById("myInput") as HTMLInputElement;
const value = (<HTMLInputElement>input).value;
 
// Type guards
function isString(value: unknown): value is string {
    return typeof value === "string";
}
 
function processValue(value: string | number) {
    if (isString(value)) {
        console.log(value.toUpperCase());
    } else {
        console.log(value.toFixed(2));
    }
}
 
// instanceof guard
function processError(error: Error | string) {
    if (error instanceof Error) {
        console.log(error.message);
    } else {
        console.log(error);
    }
}
 
// Discriminated union
interface SuccessResult {
    type: "success";
    data: unknown;
}
 
interface ErrorResult {
    type: "error";
    message: string;
}
 
type ApiResult = SuccessResult | ErrorResult;
 
function handleResult(result: ApiResult) {
    switch (result.type) {
        case "success":
            console.log(result.data);
            break;
        case "error":
            console.log(result.message);
            break;
    }
}

Architecture and Design Patterns

Interfaces vs Types

Both interfaces and types define object shapes, but interfaces are extendable and better suited for object-oriented patterns:

// Interface - extendable, declaration merging
interface User {
    id: number;
    name: string;
    email: string;
    createdAt: Date;
}
 
interface Admin extends User {
    role: "admin";
    permissions: string[];
    lastLogin?: Date;
}
 
// Type - more flexible for unions and intersections
type Result<T> = 
    | { success: true; data: T; timestamp: number }
    | { success: false; error: string; code: number };
 
type ApiResponse<T> = {
    data: T;
    status: number;
    headers: Record<string, string>;
};
 
// Intersection types
type AuthenticatedUser = User & {
    token: string;
    expiresAt: Date;
};
 
// Use interfaces for objects you might extend
// Use types for unions, intersections, and utility types

Generics for Reusable Code

Generics allow you to write code that works with multiple types while maintaining type safety:

// Generic function
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}
 
// Generic interface
interface Repository<T> {
    findById(id: string): Promise<T | null>;
    findAll(filter?: Partial<T>): Promise<T[]>;
    create(item: Omit<T, "id">): Promise<T>;
    update(id: string, item: Partial<T>): Promise<T>;
    delete(id: string): Promise<boolean>;
}
 
// Generic class
class ApiClient<T extends { id: string }> implements Repository<T> {
    private baseUrl: string;
    
    constructor(baseUrl: string) {
        this.baseUrl = baseUrl;
    }
    
    async findById(id: string): Promise<T | null> {
        const response = await fetch(`${this.baseUrl}/${id}`);
        if (!response.ok) return null;
        return response.json();
    }
    
    async findAll(filter?: Partial<T>): Promise<T[]> {
        const params = filter ? new URLSearchParams(filter as any) : "";
        const response = await fetch(`${this.baseUrl}?${params}`);
        return response.json();
    }
    
    async create(item: Omit<T, "id">): Promise<T> {
        const response = await fetch(this.baseUrl, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(item),
        });
        return response.json();
    }
    
    async update(id: string, item: Partial<T>): Promise<T> {
        const response = await fetch(`${this.baseUrl}/${id}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(item),
        });
        return response.json();
    }
    
    async delete(id: string): Promise<boolean> {
        const response = await fetch(`${this.baseUrl}/${id}`, {
            method: "DELETE",
        });
        return response.ok;
    }
}
 
// Generic constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
 
function merge<T extends object, U extends object>(a: T, b: U): T & U {
    return { ...a, ...b };
}

Utility Types

TypeScript provides built-in utility types for common type transformations:

interface User {
    id: number;
    name: string;
    email: string;
    password: string;
    createdAt: Date;
}
 
// Partial: all properties optional
type UpdateUser = Partial<User>;
 
// Required: all properties required
type RequiredUser = Required<User>;
 
// Pick: select specific properties
type UserPreview = Pick<User, "id" | "name" | "email">;
 
// Omit: exclude specific properties
type SafeUser = Omit<User, "password">;
 
// Record: construct object type
type UserRoles = Record<string, "admin" | "user" | "guest">;
 
// Extract and Exclude
type StringOrNumber = string | number | boolean;
type OnlyString = Extract<StringOrNumber, string>;
type NotBoolean = Exclude<StringOrNumber, boolean>;
 
// ReturnType
function createUser() {
    return { id: 1, name: "John" };
}
type CreateUserReturn = ReturnType<typeof createUser>;
 
// Parameters
function greet(name: string, greeting: string) {
    return `${greeting}, ${name}!`;
}
type GreetParams = Parameters<typeof greet>;

Step-by-Step Implementation

Setting Up TypeScript

Initialize a TypeScript project with recommended configuration:

# Install TypeScript globally
npm install -g typescript ts-node
 
# Create project directory
mkdir typescript-project && cd typescript-project
npm init -y
npm install --save-dev typescript @types/node
 
# Initialize TypeScript configuration
npx tsc --init

Configuring tsconfig.json

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "commonjs",
        "lib": ["ES2022"],
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "resolveJsonModule": true,
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,
        "moduleResolution": "node",
        "baseUrl": "./src",
        "paths": {
            "@/*": ["./*"],
            "@components/*": ["components/*"],
            "@utils/*": ["utils/*"]
        }
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Building a Type-Safe API Client

// types/api.ts
export interface ApiResponse<T> {
    data: T;
    status: number;
    message: string;
    pagination?: {
        page: number;
        limit: number;
        total: number;
        totalPages: number;
    };
}
 
export interface User {
    id: string;
    name: string;
    email: string;
    avatar?: string;
    role: "admin" | "user";
    createdAt: string;
}
 
export interface CreateUserDto {
    name: string;
    email: string;
    password: string;
}
 
export interface UpdateUserDto {
    name?: string;
    email?: string;
    avatar?: string;
}
 
// services/api-client.ts
class ApiClient {
    private baseUrl: string;
    private headers: HeadersInit;
    
    constructor(baseUrl: string, token?: string) {
        this.baseUrl = baseUrl;
        this.headers = {
            "Content-Type": "application/json",
            ...(token && { Authorization: `Bearer ${token}` }),
        };
    }
    
    async get<T>(endpoint: string, params?: Record<string, string>): Promise<ApiResponse<T>> {
        const url = new URL(`${this.baseUrl}${endpoint}`);
        if (params) {
            Object.entries(params).forEach(([key, value]) => {
                url.searchParams.append(key, value);
            });
        }
        
        const response = await fetch(url.toString(), {
            method: "GET",
            headers: this.headers,
        });
        
        if (!response.ok) {
            throw new ApiError(response.status, await response.text());
        }
        
        return response.json();
    }
    
    async post<T>(endpoint: string, body: unknown): Promise<ApiResponse<T>> {
        const response = await fetch(`${this.baseUrl}${endpoint}`, {
            method: "POST",
            headers: this.headers,
            body: JSON.stringify(body),
        });
        
        if (!response.ok) {
            throw new ApiError(response.status, await response.text());
        }
        
        return response.json();
    }
    
    async patch<T>(endpoint: string, body: unknown): Promise<ApiResponse<T>> {
        const response = await fetch(`${this.baseUrl}${endpoint}`, {
            method: "PATCH",
            headers: this.headers,
            body: JSON.stringify(body),
        });
        
        if (!response.ok) {
            throw new ApiError(response.status, await response.text());
        }
        
        return response.json();
    }
    
    async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
        const response = await fetch(`${this.baseUrl}${endpoint}`, {
            method: "DELETE",
            headers: this.headers,
        });
        
        if (!response.ok) {
            throw new ApiError(response.status, await response.text());
        }
        
        return response.json();
    }
}
 
class ApiError extends Error {
    constructor(public status: number, message: string) {
        super(message);
        this.name = "ApiError";
    }
}
 
// Usage
const api = new ApiClient("https://api.example.com", "auth-token");
 
async function loadUsers() {
    const { data: users } = await api.get<User[]>("/users");
    return users;
}
 
async function createUser(userData: CreateUserDto) {
    const { data: user } = await api.post<User>("/users", userData);
    return user;
}

Real-World Use Cases

Enterprise Applications

Large companies rely on TypeScript for managing complex codebases. Microsoft uses TypeScript across Azure, VS Code, and Teams. Airbnb migrated their frontend to TypeScript and reported significant reductions in production bugs.

Library and Framework Development

Most modern npm packages ship with TypeScript declarations or are written in TypeScript. Libraries like Zod, Prisma, tRPC, and TanStack Query use advanced TypeScript features to provide excellent developer experience.

Full-Stack Development

With TypeScript on both frontend and backend, teams share types and validation logic. tRPC enables end-to-end type safety from API routes to React components without code generation.

Best Practices for Production

  1. Enable strict mode: Always use "strict": true in tsconfig.json. This enables strictNullChecks, noImplicitAny, and other safety features.

  2. Avoid any: Use unknown when the type is uncertain and narrow with type guards.

  3. Use discriminated unions: Pattern match on a common property for type-safe branching.

  4. Leverage utility types: Use Partial<T>, Pick<T>, Omit<T>, and Record<K, V> to transform types.

  5. Document with JSDoc: Add JSDoc comments for better IDE tooltips and generated documentation.

  6. Use branded types: Create nominal types to prevent mixing similar primitives.

// Branded types prevent mixing
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
 
function createUserId(id: string): UserId {
    return id as UserId;
}
 
function getOrder(userId: UserId, orderId: OrderId) {
    // Cannot accidentally swap arguments
}

Common Pitfalls and Solutions

PitfallImpactSolution
Using any everywhereLoses all type safetyUse unknown and type guards
Ignoring strict null checksNull reference errorsEnable strictNullChecks
Type assertions (as)Bypasses safetyUse type guards or validation
Circular type importsBuild errorsRestructure or use interface merging
Overusing ! assertionNull errors at runtimeUse optional chaining or checks

Performance Optimization

TypeScript compilation can be slow in large projects. Use these strategies:

{
    "compilerOptions": {
        "skipLibCheck": true,
        "incremental": true,
        "tsBuildInfoFile": "./dist/.tsbuildinfo"
    }
}

Use project references for monorepo setups:

{
    "references": [
        { "path": "./packages/core" },
        { "path": "./packages/ui" },
        { "path": "./packages/utils" }
    ]
}

Comparison with Alternatives

FeatureTypeScriptFlowJSDocReScript
Type checkingCompile-timeCompile-timeIDE-onlyCompile-time
IDE supportExcellentGoodModerateGood
EcosystemLargestShrinkingUniversalSmall
Learning curveModerateModerateEasySteep
Migration pathEasyHardEasyHard

Testing Strategies

Use Jest with ts-jest for TypeScript testing:

// utils.test.ts
import { first, groupBy, debounce } from "./utils";
 
describe("first", () => {
    it("should return the first element", () => {
        expect(first([1, 2, 3])).toBe(1);
    });
    
    it("should return undefined for empty arrays", () => {
        expect(first([])).toBeUndefined();
    });
});
 
describe("groupBy", () => {
    it("should group items by key", () => {
        const items = [
            { type: "fruit", name: "apple" },
            { type: "vegetable", name: "carrot" },
            { type: "fruit", name: "banana" },
        ];
        
        const grouped = groupBy(items, "type");
        expect(grouped.fruit).toHaveLength(2);
        expect(grouped.vegetable).toHaveLength(1);
    });
});

TypeScript Compiler Configuration Deep Dive

The tsconfig.json file controls how TypeScript compiles your code. Understanding its options is essential for productive development.

Strict Mode Options

Strict mode enables multiple safety checks simultaneously. You can also enable individual checks:

// tsconfig.json - granular strict options
{
  "compilerOptions": {
    "strict": true,                    // Enables all strict options
    "strictNullChecks": true,          // null/undefined are distinct types
    "strictFunctionTypes": true,       // Function parameter contravariance
    "strictBindCallApply": true,       // Type-check bind, call, apply
    "strictPropertyInitialization": true, // Class properties must be initialized
    "noImplicitAny": true,             // Error on implicit any
    "noImplicitThis": true,            // Error on implicit this
    "alwaysStrict": true               // Emit "use strict" in JS output
  }
}

Module Resolution Strategies

The moduleResolution option controls how TypeScript finds imported modules:

// Classic resolution (legacy)
// import "./foo" → looks for foo.ts, foo.d.ts
// import "bar" → looks for bar.ts, bar.d.ts
 
// Node resolution (recommended)
// import "./foo" → looks for ./foo.ts, ./foo/index.ts, ./foo/package.json
// import "bar" → looks in node_modules/bar/
 
// Bundler resolution (for Vite, esbuild, Webpack)
// Similar to Node but allows extensionless imports
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "module": "ESNext",
    "target": "ES2022"
  }
}

Path Mapping for Monorepos

Path aliases eliminate relative import hell in large projects:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"],
      "@ui/*": ["packages/ui/src/*"],
      "@utils/*": ["packages/utils/src/*"],
      "@types/*": ["packages/types/src/*"]
    }
  }
}
// Instead of this:
import { Button } from "../../../packages/ui/src/components/Button";
 
// Write this:
import { Button } from "@ui/components/Button";

Incremental Compilation

Enable incremental compilation to speed up subsequent builds:

{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./dist/.tsbuildinfo"
  }
}

For monorepos, use project references to build packages in dependency order:

// Root tsconfig.json
{
  "references": [
    { "path": "./packages/types" },
    { "path": "./packages/utils" },
    { "path": "./packages/ui" },
    { "path": "./apps/web" }
  ]
}
# Build all packages in correct order
tsc --build
 
# Build only changed packages
tsc --build --incremental

React with TypeScript

React with TypeScript provides type-safe component development:

// Typing component props
interface ButtonProps {
  variant: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  children: React.ReactNode;
}
 
function Button({ variant, size = "md", disabled, onClick, children }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}
 
// Typing hooks
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);
 
  return { user, loading, error };
}
 
// Typing refs
function TextInput() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  const focus = () => {
    inputRef.current?.focus();
  };
 
  return <input ref={inputRef} />;
}
 
// Typing context
interface AuthContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}
 
const AuthContext = createContext<AuthContextType | null>(null);
 
function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}

Node.js with TypeScript

Run TypeScript directly in Node.js without a build step:

// Using ts-node for development
// Install: npm install -g ts-node typescript @types/node
 
// tsconfig.json for Node.js
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
 
// Express with TypeScript
import express, { Request, Response, NextFunction } from "express";
 
interface TypedRequest<T> extends Request {
  body: T;
}
 
interface CreateUserBody {
  name: string;
  email: string;
  password: string;
}
 
const app = express();
 
app.post(
  "/users",
  async (req: TypedRequest<CreateUserBody>, res: Response, next: NextFunction) => {
    try {
      const { name, email, password } = req.body;
      const user = await createUser({ name, email, password });
      res.status(201).json(user);
    } catch (error) {
      next(error);
    }
  }
);

Migration Strategy from JavaScript

Incremental Adoption with allowJs

TypeScript supports incremental migration through the allowJs compiler option:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "declaration": true,
    "outDir": "./dist"
  }
}

This lets you mix .js and .ts files in the same project. JavaScript files are type-checked using JSDoc annotations:

// utils.js - typed with JSDoc
/**
 * @param {string} name
 * @param {number} age
 * @returns {{ name: string, age: number, isAdult: boolean }}
 */
function createUser(name, age) {
  return { name, age, isAdult: age >= 18 };
}

Declaration Files for Untyped Libraries

Create .d.ts files for libraries without type declarations:

// types/my-untyped-lib.d.ts
declare module "my-untyped-lib" {
  export function doSomething(input: string): number;
  export interface Config {
    debug: boolean;
    timeout: number;
  }
  export class Client {
    constructor(config: Config);
    connect(): Promise<void>;
    disconnect(): void;
  }
}

Automated Migration Tools

Use codemods to automate parts of the migration:

# ts-migrate by Airbnb
npx ts-migrate migrate ./src --migrate js
 
# ts-migrate-full for more options
npx ts-migrate-full ./src
 
# Rename .js to .ts
find src -name "*.js" -exec sh -c 'mv "$1" "${1%.js}.ts"' _ {} \;

Future Outlook

TypeScript continues evolving with features like decorators, const type parameters, and improved inference. The TC39 Type Annotations proposal may bring optional typing to JavaScript itself, potentially changing TypeScript's role in the ecosystem.

Conclusion

TypeScript transforms JavaScript development by catching errors early and improving code quality. Start with strict mode, leverage the type system for documentation, and gradually adopt advanced features like generics and discriminated unions.

Key takeaways:

  1. TypeScript catches errors at compile time, reducing production bugs
  2. Use interfaces for extendable object shapes, types for unions
  3. Generics enable reusable, type-safe code across your application
  4. Always enable strict mode in tsconfig.json for maximum safety
  5. Utility types transform existing types without code duplication

Begin your TypeScript journey with the official handbook, practice on TypeScript Playground, and explore Type Challenges for advanced patterns.