Vite TypeScript: Strongly Typed Applications
TypeScript has become the default choice for modern web development, and Vite pairs beautifully with it to deliver lightning-fast builds, intelligent type checking, and a developer experience that feels almost magical. In this tutorial, you'll learn what strongly typed Vite applications look like, why they matter, and how to build one from the ground up using best practices.
What Is a Vite TypeScript Application?
Vite is a next-generation build tool that uses native ES modules during development and Rollup for production builds. When combined with TypeScript, it gives you a strongly typed environment where the compiler catches bugs before they reach the browser, while Vite handles instant hot module replacement (HMR) and optimized bundling.
A "strongly typed" application goes beyond simply using .ts files. It means every layer of your app — configuration, environment variables, API responses, component props, and utility functions — is governed by explicit, reliable types. This eliminates entire categories of runtime errors and makes refactoring safe.
Why Strong Typing Matters
- Catch errors early: Type errors surface in your editor, not in production.
- Better refactoring: Rename a function and the compiler tells you everywhere it breaks.
- Self-documenting code: Types serve as living documentation for your APIs.
- Improved DX: IDEs provide accurate autocomplete and inline hints.
- Team scalability: New developers understand contracts faster.
Scaffolding a Vite TypeScript Project
Start by creating a new Vite project with the TypeScript template. Run the following commands in your terminal:
npm create vite@latest my-typed-app -- --template vanilla-ts
cd my-typed-app
npm install
npm run dev
Vite also offers framework-specific TypeScript templates such as react-ts, vue-ts, svelte-ts, and preact-ts. Swap vanilla-ts for whichever fits your stack.
Understanding the Generated Structure
After scaffolding, you'll see a tsconfig.json file, a tsconfig.node.json for Vite's config itself, and a vite.config.ts file. The default tsconfig.json is a good starting point, but for a strongly typed app you should tighten it.
Configuring TypeScript for Strictness
Open tsconfig.json and enable the strictest possible settings. This is the foundation of a strongly typed application:
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"]
}
The key flags here are strict, noUnusedLocals, noUnusedParameters, and noUncheckedIndexedAccess. The last one is often overlooked but prevents unsafe array access like arr[0] from returning T instead of T | undefined.
Typing Environment Variables
Vite exposes environment variables prefixed with VITE_ through import.meta.env. By default, these are typed as any. To make them strongly typed, create an env.d.ts file in your src directory:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_KEY: string;
readonly VITE_ENABLE_ANALYTICS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Now whenever you access import.meta.env.VITE_API_URL, TypeScript knows it's a string and will warn you if you reference a variable that doesn't exist.
Creating a Typed API Client
A common source of runtime bugs is untyped API responses. Let's build a small typed HTTP client that enforces shape at the boundary. Create a file src/api/client.ts:
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
export interface Post {
id: number;
title: string;
body: string;
authorId: number;
publishedAt: string;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = import.meta.env.VITE_API_URL;
const response = await fetch(`${baseUrl}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
export const api = {
getUsers: () => request<User[]>('/users'),
getUser: (id: number) => request<User>(`/users/${id}`),
getPosts: () => request<Post[]>('/posts'),
createPost: (data: Omit<Post, 'id' | 'publishedAt'>) =>
request<Post>('/posts', {
method: 'POST',
body: JSON.stringify(data),
}),
};
Notice how request<T> is generic, so every endpoint declares its expected return type. The Omit<Post, 'id' | 'publishedAt'> utility ensures callers can't accidentally send fields the server generates.
Validating Runtime Data with Zod
TypeScript types only exist at compile time. If an API returns unexpected data, your types won't save you at runtime. Pair TypeScript with a runtime validator like Zod for true end-to-end safety:
npm install zod
Then refactor your types to be derived from Zod schemas:
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
});
export type User = z.infer<typeof userSchema>;
export function parseUser(data: unknown): User {
return userSchema.parse(data);
}
Now parseUser validates the data shape at runtime and throws a descriptive error if it doesn't match. Your User type is automatically kept in sync with the schema, so there's a single source of truth.
Building a Typed Event Bus
Strong typing shines in patterns like event buses, where untyped implementations often lead to typos and missed handlers. Here's a typed event emitter:
type EventMap = {
'user:login': { userId: number };
'user:logout': undefined;
'post:created': { postId: number; title: string };
};
type EventHandler<T> = (payload: T) => void;
class TypedEventBus<T extends Record<string, unknown>> {
private handlers: { [K in keyof T]?: Set<EventHandler<T[K]>> } = {};
on<K extends keyof T>(event: K, handler: EventHandler<T[K]>): () => void {
if (!this.handlers[event]) {
this.handlers[event] = new Set();
}
this.handlers[event]!.add(handler);
return () => this.off(event, handler);
}
off<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
this.handlers[event]?.delete(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.handlers[event]?.forEach((handler) => handler(payload));
}
}
export const bus = new TypedEventBus<EventMap>();
When you call bus.emit('user:login', { userId: 42 }), TypeScript verifies the event name and payload shape. Misspelled events or wrong payloads are caught instantly.
Typing Vite Plugins and Config
Your vite.config.ts is itself a TypeScript file. You can strongly type custom plugins using the Plugin type from Vite:
import { defineConfig, type Plugin } from 'vite';
function bannerPlugin(banner: string): Plugin {
return {
name: 'banner-plugin',
transform(code, id) {
if (id.endsWith('.js')) {
return `/* ${banner} */\n${code}`;
}
return null;
},
};
}
export default defineConfig({
plugins: [bannerPlugin('Built with Vite + TypeScript')],
build: {
target: 'es2022',
sourcemap: true,
},
});
Adding Type Checking to Your Build Pipeline
Vite uses esbuild to transpile TypeScript, which strips types without checking them. This is great for speed during development, but it means type errors won't fail your build by default. Add a separate type-check script to package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
}
}
For larger projects, consider vite-plugin-checker to run type checking in a worker during development, surfacing errors as overlays without slowing down HMR:
npm install -D vite-plugin-checker
import { defineConfig } from 'vite';
import checker from 'vite-plugin-checker';
export default defineConfig({
plugins: [
checker({
typescript: true,
}),
],
});
Best Practices for Strongly Typed Vite Apps
- Enable strict mode from day one. Retrofitting strictness onto a large codebase is painful.
- Avoid
any. Useunknownwhen you genuinely don't know the shape, then narrow it. - Validate at boundaries. Use Zod or similar for API responses, localStorage reads, and URL params.
- Prefer composition over enums. Union types and
as constobjects often compile smaller and interop better. - Keep types close to their consumers. Co-locate types with the modules that use them rather than a giant
types.tsfile. - Type your environment variables. Never let
import.meta.envfall back toany. - Run
tsc --noEmitin CI. It's your safety net against type regressions. - Use path aliases. Configure
pathsintsconfig.jsonandresolve.aliasin Vite to avoid deep relative imports.
Setting Up Path Aliases
Path aliases keep imports clean and refactor-friendly. Update both tsconfig.json and vite.config.ts so TypeScript and Vite agree:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@api/*": ["src/api/*"]
}
}
}
// vite.config.ts
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@api': fileURLToPath(new URL('./src/api', import.meta.url)),
},
},
});
Now you can write import { api } from '@/api/client' instead of import { api } from '../../api/client'.
Conclusion
Combining Vite with TypeScript gives you a development environment that is both fast and safe. By enabling strict compiler options, typing your environment variables, validating data at boundaries with Zod, and integrating type checking into your build pipeline, you create an application where bugs are caught early, refactors are fearless, and the codebase stays maintainable as it grows. Start with the strictest settings you can tolerate, layer in runtime validation where data crosses trust boundaries, and let the compiler do the heavy lifting so you can focus on shipping features with confidence.