TypeScript Tips for Frontend Developers Building Dashboards
Type API responses with zod, use utility types, avoid any, handle optional data, and build type-safe components. Web3-specific TypeScript patterns with viem types.
Why TypeScript is essential for dashboards
Dashboards consume complex, nested API responses with optional fields, union types, and numeric data that must be formatted correctly. TypeScript catches shape mismatches at compile time, autocompletes API fields in your editor, and makes refactoring safe as your data model evolves. Building a dashboard in plain JavaScript means discovering type errors in production.
Typing API responses
Define interfaces for every API response. Use zod for runtime validation at API boundaries.
// types/portfolio.ts
import { z } from 'zod';
export const TokenBalanceSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
symbol: z.string(),
name: z.string(),
decimals: z.number().int().min(0).max(18),
balance: z.string(),
usdValue: z.number().optional(),
});
export const PortfolioSchema = z.object({
address: z.string(),
tokens: z.array(TokenBalanceSchema),
totalUsd: z.number(),
updatedAt: z.string().datetime(),
});
export type TokenBalance = z.infer<typeof TokenBalanceSchema>;
export type Portfolio = z.infer<typeof PortfolioSchema>;
// Validate at API boundary
export async function fetchPortfolio(address: string): Promise<Portfolio> {
const res = await fetch(`/api/portfolio/${address}`);
const data = await res.json();
return PortfolioSchema.parse(data);
}Interfaces vs types
Use interfaces for object shapes that may be extended (API models, component props). Use types for unions, intersections, and computed types.
// Interface for extendable object shapes
interface DashboardProps {
address: string;
chainId: number;
}
// Type for unions and computed types
type TxStatus = 'pending' | 'confirmed' | 'failed';
type TokenAddress = `0x${string}`;
type FormattedBalance = TokenBalance & { display: string; usdDisplay: string };Utility types
TypeScript utility types reduce boilerplate and keep types in sync.
// Pick specific fields for a summary view
type PortfolioSummary = Pick<Portfolio, 'totalUsd' | 'updatedAt'>;
// Make all fields optional for partial updates
type PortfolioUpdate = Partial<Portfolio>;
// Extract return type of a function
type FetchResult = Awaited<ReturnType<typeof fetchPortfolio>>;
// Record for mapping token addresses to prices
type PriceMap = Record<string, number>;
// Readonly for config objects
const CHAIN_CONFIG: Readonly<Record<number, ChainConfig>> = {
1: { name: 'Ethereum', rpc: process.env.ETH_RPC! },
8453: { name: 'Base', rpc: process.env.BASE_RPC! },
};Avoiding any
Never use any for API data. Use unknown and narrow with type guards or zod.
// Bad
function processData(data: any) {
return data.tokens.map((t: any) => t.balance);
}
// Good
function processData(data: unknown): string[] {
const portfolio = PortfolioSchema.parse(data);
return portfolio.tokens.map((t) => t.balance);
}
// Type guard for runtime checks
function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}Handling optional and null data
Dashboard data is frequently loading, empty, or partially available. Model this explicitly.
// Discriminated union for data states
type DataState<T> =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'empty' }
| { status: 'success'; data: T };
function PortfolioView({ state }: { state: DataState<Portfolio> }) {
switch (state.status) {
case 'loading': return <Skeleton />;
case 'error': return <ErrorMessage message={state.error} />;
case 'empty': return <EmptyPortfolio />;
case 'success': return <PortfolioDisplay data={state.data} />;
}
}Type-safe components
Define explicit prop types for every component. Use generics for reusable data components.
// Generic table component
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
isLoading?: boolean;
onRowClick?: (row: T) => void;
}
function DataTable<T>({ data, columns, isLoading, onRowClick }: DataTableProps<T>) {
// implementation
}
// Usage is fully typed
<DataTable<Transaction>
data={transactions}
columns={txColumns}
onRowClick={(tx) => openExplorer(tx.hash)}
/>Web3 data examples
import { type Address, type Hash, formatUnits } from 'viem';
interface OnChainTokenData {
address: Address;
symbol: string;
decimals: number;
totalSupply: bigint;
}
function formatBalance(raw: bigint, decimals: number): string {
return formatUnits(raw, decimals);
}Use viem's branded types (Address, Hash) instead of plain strings for Ethereum addresses and transaction hashes.
FAQ
**Should I use zod for all API validation?** Yes for external APIs. For internal types with full control, TypeScript interfaces are sufficient.
**Is strict mode necessary?** Enable strict: true in tsconfig.json. strictNullChecks alone prevents most dashboard bugs.
**How do I type wagmi hooks?** wagmi v2 is fully typed. Pass typed ABIs and viem's Address type for compile-time contract call validation.
**When should I use enums vs union types?** Prefer string union types ('pending' | 'confirmed') over enums. They are simpler and work better with TypeScript's type narrowing.
Want to work together? I build Web3 dashboards and DeFi interfaces.