Files
gh-djankies-claude-configs-…/skills/validating-external-data/SKILL.md
2025-11-29 18:22:35 +08:00

17 KiB

name, description
name description
validating-external-data Validating external data from APIs, JSON parsing, user input, and any untrusted sources in TypeScript applications

Validation: External Data

Purpose: Ensure runtime type safety by validating all data from external sources. TypeScript types are compile-time only and erased at runtime.

When to use: Any time you receive data from outside your TypeScript code - API responses, JSON files, user input, database queries, environment variables, file uploads, or any external system.

Critical Understanding

TypeScript Types Are Compile-Time Only

This code has NO runtime safety:

interface User {
  id: string;
  email: string;
  age: number;
}

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

Why it's unsafe:

  • response.json() returns any (or unknown with strict settings)
  • Type assertion as User doesn't validate data
  • If API returns { id: 123, email: null, age: "twenty" }, TypeScript accepts it
  • Runtime errors occur when code assumes correct types

TypeScript types are documentation, not validation.

Runtime Validation is Mandatory

Safe code validates at system boundaries:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
});

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);
}

Now:

  • Data is validated at runtime
  • Throws error if data doesn't match schema
  • Type safety is guaranteed, not assumed
  • TypeScript types match runtime reality

System Boundaries

External data enters at these boundaries:

  1. HTTP APIs - fetch requests, axios, etc.
  2. WebSocket messages - real-time data streams
  3. File system - reading JSON, CSV, or other files
  4. Database queries - SQL, MongoDB, etc.
  5. Environment variables - process.env
  6. User input - forms, URL params, file uploads
  7. Third-party libraries - plugins, SDKs
  8. Message queues - RabbitMQ, Kafka, etc.

Validate at every boundary. Never trust external data.

Validation with Zod

Why Zod?

Advantages:

  • TypeScript-first design
  • Infer types from schemas (single source of truth)
  • Composable and reusable schemas
  • Excellent error messages
  • Zero dependencies
  • Full support for complex types

Alternative libraries:

  • io-ts - More functional, steeper learning curve
  • yup - Popular but not TypeScript-native
  • joi - Older, less TypeScript-friendly
  • ajv - JSON Schema validator, verbose

Zod is the best choice for TypeScript projects.

Basic Zod Patterns

Primitives:

import { z } from 'zod';

const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const nullSchema = z.null();
const undefinedSchema = z.undefined();

Objects:

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().max(120),
  verified: z.boolean(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;

Arrays:

const UsersSchema = z.array(UserSchema);

type Users = z.infer<typeof UsersSchema>;

Optional fields:

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  nickname: z.string().optional(),
  age: z.number().nullable(),
});

Default values:

const ConfigSchema = z.object({
  port: z.number().default(3000),
  host: z.string().default('localhost'),
});

API Response Validation

Basic Fetch Validation

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  userId: z.number(),
});

type Post = z.infer<typeof PostSchema>;

async function getPost(id: number): Promise<Post> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const data = await response.json();

  return PostSchema.parse(data);
}

What this does:

  1. Fetches data from external API
  2. Checks HTTP status
  3. Parses JSON
  4. Validates structure matches schema
  5. Returns typed data or throws error

Handling Validation Errors

async function getPost(id: number): Promise<Post | null> {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/posts/${id}`
    );

    if (!response.ok) {
      console.error(`HTTP error! status: ${response.status}`);
      return null;
    }

    const data = await response.json();

    return PostSchema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation failed:', error.errors);

      error.errors.forEach((err) => {
        console.error(`${err.path.join('.')}: ${err.message}`);
      });
    } else {
      console.error('Unexpected error:', error);
    }

    return null;
  }
}

ZodError provides detailed information:

  • error.errors - Array of validation errors
  • err.path - Field path that failed
  • err.message - Human-readable error message
  • err.code - Error type code

Safe Parsing (No Throws)

async function getPost(id: number): Promise<Post | null> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );

  const data = await response.json();

  const result = PostSchema.safeParse(data);

  if (result.success) {
    return result.data;
  } else {
    console.error('Validation failed:', result.error.errors);
    return null;
  }
}

safeParse returns:

  • { success: true, data: T } on success
  • { success: false, error: ZodError } on failure

Use when:

  • You want to handle errors without try/catch
  • Multiple validation attempts in a row
  • Building type-safe APIs

Complex Data Structures

Nested Objects

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
  country: z.string().length(2),
});

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  address: AddressSchema,
});

Discriminated Unions

const SuccessResponseSchema = z.object({
  status: z.literal('success'),
  data: z.object({
    id: z.string(),
    name: z.string(),
  }),
});

const ErrorResponseSchema = z.object({
  status: z.literal('error'),
  message: z.string(),
  code: z.number(),
});

const APIResponseSchema = z.discriminatedUnion('status', [
  SuccessResponseSchema,
  ErrorResponseSchema,
]);

type APIResponse = z.infer<typeof APIResponseSchema>;

function handleResponse(response: APIResponse) {
  if (response.status === 'success') {
    console.log(response.data.name);
  } else {
    console.error(response.message, response.code);
  }
}

Recursive Schemas

type Category = {
  id: string;
  name: string;
  subcategories: Category[];
};

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    subcategories: z.array(CategorySchema),
  })
);

Transform and Refine

Transform (convert data):

const TimestampSchema = z.string().transform((str) => new Date(str));

Refine (custom validation):

const PasswordSchema = z
  .string()
  .min(8)
  .refine(
    (password) => /[A-Z]/.test(password),
    { message: 'Must contain uppercase letter' }
  )
  .refine(
    (password) => /[0-9]/.test(password),
    { message: 'Must contain number' }
  );

JSON File Validation

import { readFile } from 'fs/promises';
import { z } from 'zod';

const ConfigSchema = z.object({
  database: z.object({
    host: z.string(),
    port: z.number(),
    username: z.string(),
    password: z.string(),
  }),
  server: z.object({
    port: z.number().default(3000),
    host: z.string().default('localhost'),
  }),
});

type Config = z.infer<typeof ConfigSchema>;

async function loadConfig(path: string): Promise<Config> {
  const content = await readFile(path, 'utf-8');
  const data = JSON.parse(content);

  return ConfigSchema.parse(data);
}

Environment Variable Validation

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

type Env = z.infer<typeof EnvSchema>;

function validateEnv(): Env {
  const result = EnvSchema.safeParse(process.env);

  if (!result.success) {
    console.error('Invalid environment variables:');
    console.error(result.error.errors);
    process.exit(1);
  }

  return result.data;
}

export const env = validateEnv();

Usage:

import { env } from './env';

const server = app.listen(env.PORT, () => {
  console.log(`Server running on port ${env.PORT}`);
});

Benefits:

  • Type-safe environment access
  • Fails fast on startup if misconfigured
  • Self-documenting required environment variables
  • Coercion handles string-to-number conversion

Database Query Validation

Raw SQL Results

import { Pool } from 'pg';

const pool = new Pool();

const UserRowSchema = z.object({
  id: z.string(),
  email: z.string(),
  created_at: z.string(),
});

type UserRow = z.infer<typeof UserRowSchema>;

async function getUser(id: string): Promise<UserRow> {
  const result = await pool.query(
    'SELECT id, email, created_at FROM users WHERE id = $1',
    [id]
  );

  if (result.rows.length === 0) {
    throw new Error('User not found');
  }

  return UserRowSchema.parse(result.rows[0]);
}

Database Safety Note: Use parameterized queries (with $1, $2 placeholders) to prevent SQL injection. For detailed patterns on safe query construction with Prisma, use the preventing-sql-injection skill from prisma-6 for database-specific sanitization patterns.

MongoDB Results

import { MongoClient } from 'mongodb';

const UserDocumentSchema = z.object({
  _id: z.string(),
  email: z.string(),
  name: z.string(),
});

async function getUser(id: string) {
  const client = await MongoClient.connect('mongodb://localhost');
  const db = client.db('myapp');

  const doc = await db.collection('users').findOne({ _id: id });

  if (!doc) {
    throw new Error('User not found');
  }

  return UserDocumentSchema.parse(doc);
}

WebSocket Message Validation

import { WebSocket } from 'ws';

const MessageSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('chat'),
    message: z.string(),
    userId: z.string(),
  }),
  z.object({
    type: z.literal('typing'),
    userId: z.string(),
  }),
]);

type Message = z.infer<typeof MessageSchema>;

const ws = new WebSocket('ws://localhost:8080');

ws.on('message', (data) => {
  try {
    const parsed = JSON.parse(data.toString());
    const message = MessageSchema.parse(parsed);

    if (message.type === 'chat') {
      console.log(`${message.userId}: ${message.message}`);
    } else {
      console.log(`${message.userId} is typing...`);
    }
  } catch (error) {
    console.error('Invalid message received:', error);
  }
});

Form Data Validation

Express/Node.js

import express from 'express';
import { z } from 'zod';

const CreateUserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(12),
});

app.post('/register', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    res.status(400).json({
      error: 'Validation failed',
      details: result.error.errors,
    });
    return;
  }

  const user = await createUser(result.data);

  res.json({ user });
});

React Forms

import { z } from 'zod';
import { useState } from 'react';

const LoginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

function LoginForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData(e.currentTarget);
    const data = {
      email: formData.get('email'),
      password: formData.get('password'),
    };

    const result = LoginSchema.safeParse(data);

    if (!result.success) {
      const fieldErrors: Record<string, string> = {};
      result.error.errors.forEach((err) => {
        const field = err.path[0] as string;
        fieldErrors[field] = err.message;
      });
      setErrors(fieldErrors);
      return;
    }

    await login(result.data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      {errors.email && <span>{errors.email}</span>}

      <input name="password" type="password" />
      {errors.password && <span>{errors.password}</span>}

      <button type="submit">Login</button>
    </form>
  );
}

Performance Considerations

Caching Schemas

DON'T recreate schemas on every call:

function validateUser(data: unknown) {
  const schema = z.object({ id: z.string() });
  return schema.parse(data);
}

DO create schemas once:

const UserSchema = z.object({ id: z.string() });

function validateUser(data: unknown) {
  return UserSchema.parse(data);
}

Partial Validation

Validate only needed fields:

const FullUserSchema = z.object({
  id: z.string(),
  email: z.string(),
  name: z.string(),
  address: z.object({
    street: z.string(),
    city: z.string(),
  }),
});

const UserIdSchema = FullUserSchema.pick({ id: true });

Common Mistakes

Mistake 1: Type Assertion Instead of Validation

const user = (await response.json()) as User;

This provides ZERO runtime safety.

Mistake 2: Validating After Using

const data = await response.json();
console.log(data.user.email);

UserSchema.parse(data);

Validation must come BEFORE use.

Mistake 3: Catching and Ignoring Errors

try {
  return UserSchema.parse(data);
} catch {
  return {};
}

Silently failing validation defeats its purpose.

Best Practices Checklist

  • Validate all external data at system boundaries
  • Use Zod for type-safe schemas
  • Infer TypeScript types from Zod schemas (z.infer)
  • Handle validation errors appropriately
  • Log validation failures for debugging
  • Never use type assertions on unvalidated data
  • Cache schema instances (don't recreate)
  • Validate environment variables on startup
  • Use safeParse for non-throwing validation
  • Provide user-friendly error messages

Zod v4 Features:

  • If constructing Zod schemas for external data, use the validating-schema-basics skill for type-safe schema patterns and validation best practices
  • If validating email, URL, UUID formats, use the validating-string-formats skill for Zod v4 top-level format functions
  • If handling validation errors, use the customizing-errors skill for safeParse pattern, error formatting, and user-friendly messages

Prisma 6 Database Validation:

  • If validating Prisma query results as external data sources with type-safe patterns, use the ensuring-query-type-safety skill from prisma-6 for GetPayload type inference
  • If constructing database queries from external data, use the preventing-sql-injection skill from prisma-6 for database-specific sanitization patterns and safe parameterization

Resources

Summary

Key Principles:

  1. TypeScript types are compile-time only - No runtime enforcement
  2. Validate at system boundaries - APIs, files, databases, user input
  3. Use Zod for runtime validation - Type-safe, composable, excellent errors
  4. Fail fast on invalid data - Don't process bad data
  5. Never trust external data - Always validate, even from "trusted" sources

Pattern:

const Schema = z.object({});
type Type = z.infer<typeof Schema>;

function processExternalData(data: unknown): Type {
  return Schema.parse(data);
}

This ensures your TypeScript types match runtime reality, preventing the #1 cause of production bugs in TypeScript applications.