Skip to main content
← ブログに戻る

Building Type-Safe APIs with TypeScript

5 min read著者: pycabbage
TutorialBackend

Building Type-Safe APIs with TypeScript

Type safety is crucial when building APIs. TypeScript provides powerful tools to ensure your API contracts are well-defined and maintainable. Let's explore how to build robust APIs with TypeScript.

Why Type-Safe APIs Matter

Type-safe APIs provide several benefits:

  • Compile-time error detection: Catch errors before runtime
  • Better documentation: Types serve as living documentation
  • Improved developer experience: Auto-completion and IntelliSense
  • Easier refactoring: Confidence when making changes
  • Contract enforcement: Ensure API consistency

Setting Up a TypeScript API Project

Start by initializing a new Node.js project with TypeScript:

npm init -y
npm install -D typescript @types/node tsx
npm install express
npm install -D @types/express

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Defining API Types

Start by defining your data models and API contracts:

// types/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateUserDTO {
  email: string;
  name: string;
  password: string;
}

export interface UpdateUserDTO {
  email?: string;
  name?: string;
}

export interface UserResponse {
  user: Omit<User, 'password'>;
}

Building a REST API with Express

Create type-safe Express routes:

// app.ts
import express, { Request, Response } from 'express';
import { User, CreateUserDTO, UpdateUserDTO } from './types/user';

const app = express();
app.use(express.json());

// Type-safe request handlers
interface TypedRequest<T> extends Request {
  body: T;
}

// Create user endpoint
app.post('/api/users', 
  async (req: TypedRequest<CreateUserDTO>, res: Response) => {
    const { email, name, password } = req.body;
    
    try {
      const user = await createUser({ email, name, password });
      res.status(201).json({ user });
    } catch (error) {
      res.status(400).json({ error: 'Failed to create user' });
    }
  }
);

// Update user endpoint
app.patch('/api/users/:id', 
  async (req: TypedRequest<UpdateUserDTO>, res: Response) => {
    const { id } = req.params;
    const updates = req.body;
    
    try {
      const user = await updateUser(id, updates);
      res.json({ user });
    } catch (error) {
      res.status(404).json({ error: 'User not found' });
    }
  }
);

Input Validation with Zod

Add runtime validation to ensure type safety:

import { z } from 'zod';

// Define schemas
const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  password: z.string().min(8)
});

const UpdateUserSchema = z.object({
  email: z.string().email().optional(),
  name: z.string().min(2).max(100).optional()
});

// Validation middleware
function validate<T>(schema: z.Schema<T>) {
  return (req: Request, res: Response, next: Function) => {
    try {
      schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        res.status(400).json({ errors: error.errors });
      }
    }
  };
}

// Use in routes
app.post('/api/users', 
  validate(CreateUserSchema),
  async (req: TypedRequest<CreateUserDTO>, res: Response) => {
    // Handler logic
  }
);

Building GraphQL APIs with Type Safety

For GraphQL, use TypeGraphQL for decorators-based type safety:

import { ObjectType, Field, InputType, Resolver, Query, Mutation, Arg } from 'type-graphql';

@ObjectType()
class User {
  @Field()
  id: string;

  @Field()
  email: string;

  @Field()
  name: string;

  @Field()
  createdAt: Date;
}

@InputType()
class CreateUserInput {
  @Field()
  email: string;

  @Field()
  name: string;

  @Field()
  password: string;
}

@Resolver(User)
class UserResolver {
  @Query(() => [User])
  async users(): Promise<User[]> {
    return await getAllUsers();
  }

  @Mutation(() => User)
  async createUser(
    @Arg('input') input: CreateUserInput
  ): Promise<User> {
    return await createUser(input);
  }
}

Error Handling

Create type-safe error handling:

class ApiError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public errors?: any[]
  ) {
    super(message);
  }
}

// Error handler middleware
app.use((err: ApiError, req: Request, res: Response, next: Function) => {
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';
  
  res.status(statusCode).json({
    error: {
      message,
      errors: err.errors
    }
  });
});

API Documentation with OpenAPI

Generate OpenAPI documentation from TypeScript types:

import { OpenAPIV3 } from 'openapi-types';

const openApiDoc: OpenAPIV3.Document = {
  openapi: '3.0.0',
  info: {
    title: 'User API',
    version: '1.0.0'
  },
  paths: {
    '/api/users': {
      post: {
        summary: 'Create a new user',
        requestBody: {
          content: {
            'application/json': {
              schema: {
                $ref: '#/components/schemas/CreateUserDTO'
              }
            }
          }
        },
        responses: {
          '201': {
            description: 'User created successfully'
          }
        }
      }
    }
  }
};

Testing Type-Safe APIs

Write type-safe tests:

import supertest from 'supertest';
import { app } from './app';

describe('User API', () => {
  it('should create a user', async () => {
    const userData: CreateUserDTO = {
      email: '[email protected]',
      name: 'Test User',
      password: 'securepassword'
    };

    const response = await supertest(app)
      .post('/api/users')
      .send(userData)
      .expect(201);

    expect(response.body.user).toMatchObject({
      email: userData.email,
      name: userData.name
    });
  });
});

Best Practices

  1. Use DTOs: Separate input/output types from domain models
  2. Validate inputs: Always validate at runtime, not just compile time
  3. Handle errors gracefully: Use consistent error responses
  4. Document your API: Generate docs from your types
  5. Test thoroughly: Ensure type safety in tests
  6. Version your API: Plan for backward compatibility

Conclusion

Building type-safe APIs with TypeScript provides confidence, better developer experience, and more maintainable code. By leveraging TypeScript's type system along with proper validation and documentation, you can create robust APIs that are a pleasure to work with.

Remember: types are not a silver bullet - combine them with proper validation, testing, and error handling for production-ready APIs!