TypeScript Practices
Type-first TypeScript coding practices — designing types before implementation, making illegal states unrepresentable with discriminated unions, runtime validation with Zod at system boundaries, and module organization (single-function modules, barrel files, encapsulation). Use when starting a new feature by asking what the types should look like first, spotting an interface with too many optional fields that let impossible combinations compile, or deciding how to validate data coming from outside the type system (an API response, form input, environment variables).
npx skills add github:pantheon-org/tekhne --skill development--typescript-advanced--practicesWorkflow-level habits: design types first, make bad states impossible to construct, validate at the boundary where untyped data enters, and organize modules so the type-first discipline stays easy to keep up.
Mindset
Design the types before writing the implementation — a type is a specification the compiler checks on every subsequent change, and writing it first forces the impossible states question to be answered up front rather than discovered later as a bug. An interface with many optional fields is usually a discriminated union that hasn’t been modeled yet: if data and error can theoretically both be set, that combination will eventually occur, and the fix is a type that excludes it, not a runtime check that catches it after the fact. Runtime validation belongs at the boundary — the one place untyped data (an API response, form input, an environment variable) enters the typed world — not scattered through the code that consumes it; validate once there, and let the type system carry the guarantee everywhere downstream.
When to Apply
Use this skill when:
- Starting a new feature and deciding what the types should look like before writing implementation
- Spotting an interface with many optional fields that allow an impossible combination to compile
- Validating data at a system boundary (API responses, form input, environment variables) with Zod
- Deciding how to organize a module’s exports (single-function modules, barrel files, internal encapsulation)
Use When
- “Where do I even start typing this feature?”
- “This interface has five optional fields — some combinations don’t make sense. How do I fix that?”
- “How do I validate this API response is actually shaped the way I expect?”
- “Should this be one big module or several small ones?”
Scope
In Scope
- Type-first development workflow.
- Making illegal states unrepresentable with discriminated unions.
- Runtime validation with Zod at system boundaries.
- Module organization patterns (single-function modules, barrel exports, encapsulation).
Out of Scope
- The type-guard/predicate mechanics used to validate at a boundary — see the sibling
typescript-type-guardsskill for hand-written predicates. - The discriminated-union type mechanics themselves — see the sibling
typescript-type-systemskill; this skill covers the practice of reaching for one, not how unions narrow. - Compiler configuration — see the sibling
typescript-compiler-configskill.
When NOT to Use
Do not use this skill to look up narrowing syntax or how a discriminated union’s exhaustiveness check works mechanically — see typescript-type-guards for that. Do not use it as a substitute for tsc --noEmit — a type-first design still needs the compiler to confirm every consumer respects it.
Quick Commands
Type Check
npx tsc --noEmit
Validate at a Boundary (Zod)
npm install zod
Anti-Patterns
NEVER model a set of mutually exclusive fields as independent optionals
WHY: independent optional fields let combinations compile that should be impossible, so the invalid combination becomes a runtime bug instead of a compile error.
BAD:
interface FetchState<T> {
isLoading: boolean;
data?: T;
error?: Error; // data and error can both be set — impossible in practice, allowed by the type
}
GOOD:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; data: T }
| { status: "done"; error: Error };
NEVER trust external data without validating it at the boundary
WHY: a type annotation on fetch().json() is a claim, not a check — the actual runtime value could be anything, and nothing enforces the annotation matches reality.
BAD:
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // asserted to be User, never verified
}
GOOD:
const UserSchema = z.object({ id: z.string(), name: z.string() });
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json());
}
NEVER let implementation details leak past a module’s intended public surface
WHY: exporting internals a module wasn’t designed to expose removes the compiler’s ability to catch a caller depending on something that was meant to change freely.
BAD:
// db.ts
export let connection: Connection; // internal state exported directly
export function connect() { connection = createConnection(); }
GOOD:
// db.ts
let connection: Connection;
export function connect() { connection = createConnection(); }
export function getConnection(): Connection {
if (!connection) throw new Error("Not connected");
return connection;
}
References
| File | Covers |
|---|---|
references/type-first.md |
Type-first development workflow |
references/illegal-states.md |
Making illegal states unrepresentable with discriminated unions |
references/runtime-validation.md |
Zod-based validation at system boundaries |
references/module-patterns.md |
Single-function modules, barrel exports, encapsulation |
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
No eval scenarios available.
Making Illegal States Unrepresentable
Use the type system to prevent invalid states at compile time. If an invalid combination of values cannot be represented in the type system, it cannot occur at runtime.
Core Principle
Bad design allows invalid states:
interface RequestState {
loading: boolean;
data?: string;
error?: Error;
}
// All of these are possible but invalid:
const invalid1: RequestState = { loading: true, data: 'result', error: new Error() };
const invalid2: RequestState = { loading: false }; // No data and no error
Good design makes invalid states unrepresentable:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; error: Error };
// Compiler prevents invalid combinations
function processRequest(state: RequestState) {
if (state.status === 'success') {
console.log(state.data); // ✓ data is always available
}
if (state.status === 'error') {
console.log(state.error); // ✓ error is always available
}
}
Pattern: Discriminated Unions
Use a discriminant field to distinguish between union variants:
type PaymentMethod =
| { type: 'credit_card'; cardNumber: string; cvv: string }
| { type: 'paypal'; email: string }
| { type: 'bank_transfer'; accountNumber: string; routingNumber: string };
function processPayment(method: PaymentMethod) {
switch (method.type) {
case 'credit_card':
// TypeScript knows cardNumber and cvv are available
return chargeCreditCard(method.cardNumber, method.cvv);
case 'paypal':
// TypeScript knows email is available
return chargePaypal(method.email);
case 'bank_transfer':
// TypeScript knows accountNumber and routingNumber are available
return chargeBankTransfer(method.accountNumber, method.routingNumber);
}
}
Pattern: Branded Types
Prevent mixing incompatible IDs:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getOrder(id: OrderId): Promise<Order> { /* ... */ }
// Constructor functions enforce validation
function createUserId(id: string): UserId {
if (!id.match(/^user_[0-9a-f]+$/)) {
throw new Error('Invalid user ID format');
}
return id as UserId;
}
function createOrderId(id: string): OrderId {
if (!id.match(/^order_[0-9a-f]+$/)) {
throw new Error('Invalid order ID format');
}
return id as OrderId;
}
const userId = createUserId('user_123');
const orderId = createOrderId('order_456');
getUser(userId); // ✓ OK
getUser(orderId); // ✗ Compile error: can't pass OrderId to UserId parameter
Pattern: Required vs Optional Fields
Be explicit about what fields are required in different contexts:
// Creation: email and name required
type CreateUser = {
email: string;
name: string;
};
// Update: all fields optional
type UpdateUser = Partial<CreateUser>;
// Database row: all fields present including generated ones
type User = CreateUser & {
id: string;
createdAt: Date;
updatedAt: Date;
};
// Public API response: no sensitive fields
type UserResponse = Omit<User, 'email'>;
Pattern: Const Assertions for Unions
Keep arrays and types in sync automatically:
const ROLES = ['admin', 'user', 'guest'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
// Adding a role to the array automatically adds it to the type
const EXTENDED_ROLES = [...ROLES, 'moderator'] as const;
type ExtendedRole = typeof EXTENDED_ROLES[number];
// Type guard based on const array
function isValidRole(role: string): role is Role {
return ROLES.includes(role as Role);
}
Pattern: Exhaustive Switch Checks
Use never to catch unhandled cases at compile time:
type Status = 'active' | 'inactive' | 'pending';
function processStatus(status: Status): string {
switch (status) {
case 'active':
return 'processing';
case 'inactive':
return 'skipped';
case 'pending':
return 'queued';
default: {
// If we add a new status and forget to handle it,
// this will be a compile error
const _exhaustive: never = status;
throw new Error(`unhandled status: ${_exhaustive}`);
}
}
}
Real-World Example: Form State
Bad: Allows invalid states
interface FormState {
pristine: boolean;
dirty: boolean;
submitting: boolean;
submitted: boolean;
valid: boolean;
errors?: Record<string, string>;
}
// Invalid: both pristine and dirty can be true
// Invalid: can be submitting and submitted
// Invalid: can be valid but have errors
Good: Only valid states possible
type FormState =
| { status: 'pristine' }
| { status: 'editing'; errors?: Record<string, string> }
| { status: 'submitting'; errors?: Record<string, string> }
| { status: 'submitted'; result: SubmitResult }
| { status: 'error'; error: Error };
function handleSubmit(state: FormState) {
if (state.status === 'submitting' || state.status === 'submitted') {
// Can't submit while already submitting or after submitted
return;
}
if (state.status === 'editing' && state.errors) {
// Can't submit with validation errors
return;
}
// Submit logic
}
Benefits
- Compiler enforces correctness - Invalid states are compile errors
- Less defensive code - No need to check for impossible conditions
- Better refactoring - Adding/removing states updates all usage sites
- Self-documenting - Type definition shows all possible states
References
Module Organization Patterns
Single-Function Modules
// src/utils/formatDate.ts
export const formatDate = (date: Date): string => {
return date.toISOString().split('T')[0];
};
// src/utils/parseDate.ts
export const parseDate = (str: string): Date => {
return new Date(str);
};
// src/utils/index.ts (barrel export)
export { formatDate } from './formatDate.js';
export { parseDate } from './parseDate.js';
Feature-Based Organization
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ ├── types.ts
│ │ └── index.ts
│ ├── users/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ ├── types.ts
│ │ └── index.ts
│ └── dashboard/
│ ├── components/
│ ├── hooks/
│ ├── api/
│ ├── types.ts
│ └── index.ts
└── shared/
├── components/
├── hooks/
└── utils/
Collocated Tests
src/
├── utils/
│ ├── formatDate.ts
│ ├── formatDate.test.ts
│ ├── parseDate.ts
│ └── parseDate.test.ts
Type-Only Exports
// types.ts
export type User = {
id: string;
name: string;
};
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// consumer.ts
import type { User, Result } from './types.js';
Barrel Exports
// components/index.ts
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';
export type { ButtonProps } from './Button.js';
export type { InputProps } from './Input.js';
// Usage
import { Button, Input } from './components/index.js';
import type { ButtonProps } from './components/index.js';
Module Boundaries
// Good: Clear module boundaries
// auth/api.ts
export const login = async (credentials: Credentials): Promise<User> => {
// ...
};
// auth/index.ts
export { login } from './api.js';
export type { Credentials, User } from './types.js';
// Bad: Leaky abstractions
// auth/index.ts
export { login } from './api.js';
export { validatePassword } from './internal/validation.js'; // Internal detail
Dependency Direction
// Good: Dependencies point inward
// domain/user.ts (core domain)
export type User = {
id: string;
email: string;
};
// api/userApi.ts (infrastructure)
import type { User } from '../domain/user.js';
export const fetchUser = async (id: string): Promise<User> => {
// ...
};
// Bad: Core depends on infrastructure
// domain/user.ts
import { fetchUser } from '../api/userApi.js'; // Wrong direction
Facade Pattern
// database/index.ts (facade)
import { createConnection } from './connection.js';
import { createQueryBuilder } from './queryBuilder.js';
import { createMigrationRunner } from './migrations.js';
export const createDatabase = () => {
const connection = createConnection();
const queryBuilder = createQueryBuilder(connection);
const migrationRunner = createMigrationRunner(connection);
return {
query: queryBuilder.query,
execute: queryBuilder.execute,
migrate: migrationRunner.run,
};
};
// Usage
import { createDatabase } from './database/index.js';
const db = createDatabase();
Adapter Pattern
// ports/logger.ts (interface)
export interface Logger {
info(message: string): void;
error(message: string, error?: Error): void;
}
// adapters/consoleLogger.ts
import type { Logger } from '../ports/logger.js';
export const createConsoleLogger = (): Logger => ({
info: (message) => console.log(`[INFO] ${message}`),
error: (message, error) =>
console.error(`[ERROR] ${message}`, error),
});
// adapters/fileLogger.ts
import type { Logger } from '../ports/logger.js';
export const createFileLogger = (filePath: string): Logger => ({
info: (message) => {
/* write to file */
},
error: (message, error) => {
/* write to file */
},
});
Monorepo Package Structure
packages/
├── core/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── ui/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
└── api/
├── src/
├── package.json
└── tsconfig.json
Best Practices
- One function per file - Easy to find, test, and understand
- Collocate related files - Tests, types, and implementation together
- Use barrel exports - Clean public API
- Feature-based folders - Group by feature, not by type
- Clear module boundaries - Public API vs internal implementation
- Dependency direction - Core domain doesn’t depend on infrastructure
- Type-only imports - Use
import typewhen possible - Avoid circular dependencies - Refactor to break cycles
Anti-Patterns
// Bad: Circular dependencies
// a.ts
import { b } from './b.js';
export const a = () => b();
// b.ts
import { a } from './a.js';
export const b = () => a();
// Bad: Deep nesting
src/components/shared/common/ui/base/Button.tsx
// Good: Flat structure
src/components/Button.tsx
// Bad: Mixed concerns
// userService.ts contains database, validation, and business logic
// Good: Separated concerns
// domain/user.ts - business logic
// repositories/userRepository.ts - database
// validators/userValidator.ts - validation
Runtime Validation Best Practices
Zod Integration
import { z } from 'zod';
// Define schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
});
// Infer type from schema
type User = z.infer<typeof UserSchema>;
// Validate at trust boundaries
export const createUser = (data: unknown): User => {
return UserSchema.parse(data); // Throws on validation failure
};
// Safe parsing for user input
export const validateUser = (data: unknown): Result<User, z.ZodError> => {
const result = UserSchema.safeParse(data);
if (result.success) {
return { ok: true, value: result.data };
}
return { ok: false, error: result.error };
};
Schema Composition
// Base schemas
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
});
const ContactSchema = z.object({
email: z.string().email(),
phone: z.string().optional(),
});
// Compose schemas
const CustomerSchema = z.object({
name: z.string(),
address: AddressSchema,
contact: ContactSchema,
metadata: z.record(z.string(), z.unknown()),
});
// Extend schemas
const PremiumCustomerSchema = CustomerSchema.extend({
tier: z.literal('premium'),
discount: z.number().min(0).max(100),
});
// Pick/omit fields
const PublicCustomerSchema = CustomerSchema.pick({
name: true,
contact: true,
});
Transform and Refine
// Transform data during validation
const DateSchema = z.string().transform((str) => new Date(str));
const NormalizedEmailSchema = z
.string()
.email()
.transform((email) => email.toLowerCase().trim());
// Custom validation with refine
const PasswordSchema = z
.string()
.min(8)
.refine((pwd) => /[A-Z]/.test(pwd), {
message: 'Must contain uppercase letter',
})
.refine((pwd) => /[0-9]/.test(pwd), {
message: 'Must contain number',
});
// Complex validation
const RangeSchema = z
.object({
min: z.number(),
max: z.number(),
})
.refine((data) => data.max > data.min, {
message: 'Max must be greater than min',
path: ['max'],
});
API Request Validation
// Express middleware
import { Request, Response, NextFunction } from 'express';
const validateRequest = <T>(schema: z.Schema<T>) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.format(),
});
}
req.body = result.data;
next();
};
};
// Usage
app.post('/users', validateRequest(UserSchema), (req, res) => {
const user = req.body; // Type-safe and validated
// ... create user
});
Configuration Validation
// Environment configuration
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform((s) => parseInt(s, 10)),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
FEATURE_FLAGS: z
.string()
.transform((s) => s.split(','))
.default(''),
});
// Load and validate at startup
export const config = ConfigSchema.parse(process.env);
Best Practices
- Validate at trust boundaries - Parse external data (API requests, file uploads, env vars)
- Use schemas as source of truth - Derive types with
z.infer<> - Prefer safeParse for user input - Return results instead of throwing
- Compose schemas - Build complex schemas from simple ones
- Add custom error messages - Make validation errors actionable
- Transform during validation - Normalize data (lowercase emails, parse dates)
- Validate early - Fail fast at application boundaries
- Test validation logic - Write tests for schema validation
Common Patterns
// Optional with default
const SettingsSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
language: z.string().default('en'),
});
// Nullable vs optional
const ProfileSchema = z.object({
bio: z.string().optional(), // Field may be missing
avatar: z.string().nullable(), // Field present but may be null
website: z.string().nullish(), // May be missing or null
});
// Discriminated union validation
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
]);
// Recursive schemas
type Category = {
name: string;
subcategories: Category[];
};
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);
Type-First Development
Type-first development means defining types before implementation. This workflow ensures type safety from the start and lets the compiler guide completeness.
Workflow
- Define the data model - types, interfaces, and schemas first
- Define function signatures - input/output types before logic
- Implement to satisfy types - let the compiler guide completeness
- Validate at boundaries - runtime checks where data enters the system
Example: API Endpoint Type-First
// 1. Define data model first
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;
type UpdateUserRequest = Partial<CreateUserRequest>;
// 2. Define function signatures
async function createUser(req: CreateUserRequest): Promise<User>;
async function updateUser(id: string, req: UpdateUserRequest): Promise<User>;
async function getUser(id: string): Promise<User | null>;
// 3. Implement to satisfy types
async function createUser(req: CreateUserRequest): Promise<User> {
const id = crypto.randomUUID();
const createdAt = new Date();
const user: User = { ...req, id, createdAt };
await db.users.insert(user);
return user;
}
Benefits
- Compiler catches missing cases - Exhaustiveness checking on switch statements
- IDE provides autocomplete - Types guide what properties/methods are available
- Refactoring is safer - Type errors show all affected code
- Documentation is built-in - Types serve as contracts
- Runtime errors reduced - Many bugs caught at compile time
Type-First vs Code-First
Code-First (avoid):
// Implementation first, types inferred
function processData(data) {
if (data.status === 'active') {
return data.items.map(i => i.value);
}
return [];
}
Type-First (preferred):
// Types first, implementation second
type Status = 'active' | 'inactive';
interface DataItem {
value: number;
}
interface Data {
status: Status;
items: DataItem[];
}
function processData(data: Data): number[] {
if (data.status === 'active') {
return data.items.map(i => i.value);
}
return [];
}
Integration with Runtime Validation
Types are compile-time only. Use Zod to bridge compile and runtime:
import { z } from 'zod';
// Schema as source of truth
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.date(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Validate at runtime boundaries
function parseUser(data: unknown): User {
return UserSchema.parse(data);
}
When to Define Types
- Domain models - Always define first
- API contracts - Define before implementing endpoints
- Complex business logic - Define input/output types first
- Library exports - Public API types defined first
- Internal utilities - Can rely on type inference for simple helpers