Chapter 12: TypeScript in the Wild (React, Node, and Beyond)

You’ve learned TypeScript. Now let’s see it in context—the frameworks and libraries where TypeScript really earns its keep.

This chapter covers the most common real-world scenarios: React, Node.js, and other popular stacks.

React + TypeScript

React and TypeScript are a natural fit. Components have clear inputs (props) and outputs (JSX). TypeScript makes this explicit.

Setup

# Create React App (legacy)
npx create-react-app my-app --template typescript

# Vite (modern, recommended)
npm create vite@latest my-app -- --template react-ts

# Next.js
npx create-next-app@latest --typescript

Functional Components

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

function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {label}
    </button>
  );
}

Or with React.FC (not recommended, but common):

const Button: React.FC<ButtonProps> = ({ label, onClick, disabled = false, variant = 'primary' }) => {
  return <button onClick={onClick} disabled={disabled} className={`btn btn-${variant}`}>{label}</button>;
};

Why not React.FC?

Props with Children

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

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

React.ReactNode is the type for anything renderable (elements, strings, numbers, fragments, etc.).

Event Handlers

function Form() {
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // Handle form submission
  };

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

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    console.log('Button clicked');
  };

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

Common event types:

Hooks

useState

const [count, setCount] = useState(0); // Inferred as number
const [name, setName] = useState(''); // Inferred as string

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

useRef

const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  inputRef.current?.focus();
}, []);

return <input ref={inputRef} />;

useReducer

interface State {
  count: number;
}

type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Custom Hooks

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

The as const ensures the return type is [T, Dispatch<SetStateAction<T>>] instead of (T | Dispatch<SetStateAction<T>>)[].

Context

interface AuthContextValue {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    const user = await apiLogin(email, password);
    setUser(user);
  };

  const logout = () => {
    setUser(null);
  };

  return (
    <AuthContext.Provider value=>
      {children}
    </AuthContext.Provider>
  );
}

function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

Higher-Order Components (Legacy)

function withLoading<P extends object>(Component: React.ComponentType<P>) {
  return function WithLoadingComponent({ isLoading, ...props }: P & { isLoading: boolean }) {
    if (isLoading) return <div>Loading...</div>;
    return <Component {...(props as P)} />;
  };
}

const ButtonWithLoading = withLoading(Button);

HOCs are less common now. Hooks replaced most use cases.

Generic Components

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage
<List
  items={[1, 2, 3]}
  renderItem={(num) => <span>{num * 2}</span>}
/>

React Router

import { useParams, useNavigate } from 'react-router-dom';

interface RouteParams {
  id: string;
}

function UserProfile() {
  const { id } = useParams<RouteParams>();
  const navigate = useNavigate();

  useEffect(() => {
    fetchUser(id);
  }, [id]);

  return <div>User {id}</div>;
}

Node.js + TypeScript

Setup

npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

tsconfig.json for Node:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

Express

import express, { Request, Response, NextFunction } from 'express';

const app = express();

app.use(express.json());

interface CreateUserRequest {
  name: string;
  email: string;
}

app.post('/users', (req: Request<{}, {}, CreateUserRequest>, res: Response) => {
  const { name, email } = req.body;
  // name and email are typed
  res.json({ id: '123', name, email });
});

// Typed params
app.get('/users/:id', (req: Request<{ id: string }>, res: Response) => {
  const { id } = req.params;
  res.json({ id, name: 'Alice' });
});

// Error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Fastify

import Fastify from 'fastify';

const server = Fastify();

interface CreateUserBody {
  name: string;
  email: string;
}

server.post<{ Body: CreateUserBody }>('/users', async (request, reply) => {
  const { name, email } = request.body;
  return { id: '123', name, email };
});

server.listen({ port: 3000 });

NestJS (TypeScript-first)

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id, name: 'Alice' };
  }

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return { id: '123', ...createUserDto };
  }
}

NestJS uses decorators heavily. Enable experimentalDecorators: true in tsconfig.json.

File System Operations

import { promises as fs } from 'fs';
import * as path from 'path';

async function readConfig(): Promise<{ port: number; host: string }> {
  const configPath = path.join(__dirname, 'config.json');
  const content = await fs.readFile(configPath, 'utf-8');
  return JSON.parse(content);
}

Working with Streams

import { createReadStream } from 'fs';
import { pipeline } from 'stream/promises';
import { createGzip } from 'zlib';

async function compressFile(inputPath: string, outputPath: string) {
  await pipeline(
    createReadStream(inputPath),
    createGzip(),
    fs.createWriteStream(outputPath)
  );
}

Database Integration

Prisma (Type-safe ORM)

npm install @prisma/client
npm install --save-dev prisma
npx prisma init

schema.prisma:

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User    @relation(fields: [authorId], references: [id])
  authorId  Int
}

Generated TypeScript:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.create({
    data: {
      email: 'alice@example.com',
      name: 'Alice',
      posts: {
        create: { title: 'Hello World' }
      }
    }
  });
  // user is fully typed based on schema
}

TypeORM

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  email: string;
}

// Usage
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({ where: { id: 1 } });

GraphQL

Apollo Server

import { ApolloServer, gql } from 'apollo-server';

const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
  }
`;

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

const resolvers = {
  Query: {
    user: (_: unknown, { id }: { id: string }): User => {
      return { id, name: 'Alice', email: 'alice@example.com' };
    },
    users: (): User[] => {
      return [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
    }
  }
};

const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => console.log(`Server ready at ${url}`));

GraphQL Code Generator

Auto-generate types from schema:

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript

codegen.yml:

schema: 'http://localhost:4000/graphql'
documents: 'src/**/*.graphql'
generates:
  src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations

Now queries are fully typed:

import { useQuery } from '@apollo/client';
import { GetUserQuery, GetUserQueryVariables } from './generated/graphql';

const { data } = useQuery<GetUserQuery, GetUserQueryVariables>(GET_USER, {
  variables: { id: '123' }
});

console.log(data?.user?.name); // Fully typed

Testing

Jest with TypeScript

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

// math.test.ts
import { add } from './math';

describe('add', () => {
  it('adds two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(add(-1, 1)).toBe(0);
  });
});

React Testing Library

import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

test('button click triggers callback', () => {
  const handleClick = jest.fn();
  render(<Button label="Click me" onClick={handleClick} />);

  const button = screen.getByText('Click me');
  fireEvent.click(button);

  expect(handleClick).toHaveBeenCalledTimes(1);
});

Type-Safe Mocks

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

const mockUser: User = {
  id: '123',
  name: 'Alice',
  email: 'alice@example.com'
};

jest.mock('./api', () => ({
  fetchUser: jest.fn((): Promise<User> => Promise.resolve(mockUser))
}));

Deno in Production

Remember, Deno runs TypeScript natively:

// server.ts
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";

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

serve(async (req: Request) => {
  const url = new URL(req.url);

  if (url.pathname === '/users') {
    const users: User[] = [{ id: '1', name: 'Alice' }];
    return new Response(JSON.stringify(users), {
      headers: { 'content-type': 'application/json' }
    });
  }

  return new Response('Not found', { status: 404 });
});
deno run --allow-net server.ts

No build step. TypeScript in production.

Best Practices (Real-World)

1. Co-locate Types with Code

// Bad: types in separate file
// types.ts
export interface User { /* ... */ }

// user.ts
import { User } from './types';

// Good: types with implementation
// user.ts
export interface User { /* ... */ }
export function createUser(name: string): User { /* ... */ }

2. Use Branded Types for IDs

type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = 'user-123' as UserId;
const postId = 'post-456' as PostId;

getUser(userId); // OK
getUser(postId); // Error

3. Prefer Unknown Over Any

// Bad
function parse(data: any) {
  return data.value;
}

// Good
function parse(data: unknown): number {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as { value: number }).value;
  }
  throw new Error('Invalid data');
}

4. Use Exhaustive Checks

type Status = 'pending' | 'approved' | 'rejected';

function handleStatus(status: Status) {
  switch (status) {
    case 'pending':
      return 'Waiting...';
    case 'approved':
      return 'Approved!';
    case 'rejected':
      return 'Rejected.';
    default:
      const exhaustive: never = status;
      throw new Error(`Unhandled status: ${exhaustive}`);
  }
}

5. Avoid Enums (Use Union Types)

// Bad
enum Status {
  Pending = 'PENDING',
  Approved = 'APPROVED',
  Rejected = 'REJECTED'
}

// Good
type Status = 'pending' | 'approved' | 'rejected';

Enums have runtime overhead. Union types are just types.

What You’ve Learned

TypeScript transforms how you build applications. Types catch bugs, improve refactoring, and make large codebases manageable.

You’re no longer a JavaScript developer learning TypeScript. You’re a TypeScript developer who understands JavaScript.


Conclusion

You started this book as an experienced JavaScript developer. You knew closures, prototypes, async/await, and the module system. You’d shipped code.

Now you understand TypeScript: its type system, toolchain, advanced features, ecosystem, and real-world application. You know when to use strict types, when to use any, when to fight the compiler, and when to trust it.

TypeScript isn’t a silver bullet. It won’t eliminate bugs. It won’t make bad code good. But it will catch entire classes of errors at compile time, make refactoring safer, and encode your design intent in a way the computer can verify.

The JavaScript you write won’t change much. The confidence you have in it will.

Welcome to TypeScript. Now go build something.