What is Jest with TypeScript?
Jest is a delightful JavaScript testing framework developed by Meta, known for its zero-config philosophy, snapshot testing, and built-in mocking capabilities. When combined with TypeScript, it becomes an even more powerful tool — one that brings compile-time type safety directly into your test suite. Jest with TypeScript means writing your test files in .ts or .tsx format, leveraging TypeScript's static analysis to catch type errors before your tests ever run.
The integration works through one of two primary transformers: ts-jest, a dedicated Jest transformer that compiles TypeScript on the fly, or @babel/preset-typescript via Babel. The modern recommended approach is ts-jest, as it provides full type-checking during test execution rather than merely stripping type annotations.
Why Strongly Typed Tests Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →At first glance, adding types to tests might seem like overhead. After all, tests don't ship to production. But strongly typed tests provide four critical benefits that compound as your codebase grows:
- Refactoring confidence: When you change a function signature, typed tests immediately flag mismatched arguments across your entire test suite. Without types, broken tests silently pass incorrect data.
- Living documentation: Type annotations in test fixtures and mock setups serve as executable specifications of your data structures. A new team member can read typed test data and instantly understand the shape of domain objects.
- Mock integrity: Typed mocks ensure your mock implementations actually match the interfaces they're standing in for. If the real interface gains a method, your typed mock compilation fails rather than silently returning
undefined. - IDE productivity: Autocompletion, inline documentation, and "go to definition" work inside test files exactly as they do in production code, dramatically speeding up test authoring.
Project Setup
Let's walk through setting up a TypeScript project with Jest from scratch. You'll need Node.js 16+ and either npm or yarn.
Step 1: Initialize the Project and Install Dependencies
mkdir my-typed-app
cd my-typed-app
npm init -y
npm install --save-dev jest ts-jest @types/jest typescript
This installs Jest, the TypeScript Jest transformer, Jest's type definitions (which provide type-safe matchers like expect and jest.fn()), and the TypeScript compiler itself.
Step 2: Create tsconfig.json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["jest", "node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules"]
}
The "types": ["jest", "node"] line is crucial — it merges Jest's global type declarations (like describe, it, expect) into your TypeScript compilation context so you don't need to import them explicitly in every test file.
Step 3: Configure Jest
Create a jest.config.ts file. Yes, the Jest configuration itself can be TypeScript thanks to ts-jest:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
The preset: 'ts-jest' directive tells Jest to use ts-jest for transforming .ts files. The configuration is strongly typed via the Config import from Jest, giving you autocompletion and validation on the configuration object itself.
Step 4: Add npm Scripts
// package.json (partial)
{
"scripts": {
"test": "jest --verbose",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
Writing Your First Typed Test
Let's build a small service to test. We'll model a user management system with strict typing throughout.
Production Code: The User Service
// src/models/user.ts
export interface User {
id: string;
email: string;
displayName: string;
createdAt: Date;
roles: Role[];
}
export enum Role {
Admin = 'ADMIN',
Editor = 'EDITOR',
Viewer = 'VIEWER',
}
// src/services/user-service.ts
import { User, Role } from '../models/user';
export class UserService {
private users: Map<string, User> = new Map();
createUser(email: string, displayName: string): User {
if (!email.includes('@')) {
throw new Error('Invalid email address');
}
const user: User = {
id: this.generateId(),
email,
displayName,
createdAt: new Date(),
roles: [Role.Viewer],
};
this.users.set(user.id, user);
return user;
}
assignRole(userId: string, role: Role): User {
const user = this.users.get(userId);
if (!user) {
throw new Error(`User with id ${userId} not found`);
}
const updatedUser: User = {
...user,
roles: [...user.roles, role],
};
this.users.set(userId, updatedUser);
return updatedUser;
}
getUserById(userId: string): User | undefined {
return this.users.get(userId);
}
getAllUsers(): User[] {
return Array.from(this.users.values());
}
private generateId(): string {
return `user_${Math.random().toString(36).substring(2, 10)}`;
}
}
Test File with Full Type Safety
// tests/services/user-service.test.ts
import { UserService } from '../../src/services/user-service';
import { User, Role } from '../../src/models/user';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe('createUser', () => {
it('should create a user with valid email and default Viewer role', () => {
const email = 'alice@example.com';
const displayName = 'Alice Johnson';
const user: User = service.createUser(email, displayName);
// TypeScript ensures user matches the User interface
expect(user.email).toBe(email);
expect(user.displayName).toBe(displayName);
expect(user.roles).toContain(Role.Viewer);
expect(user.id).toMatch(/^user_/);
expect(user.createdAt).toBeInstanceOf(Date);
});
it('should throw on invalid email', () => {
expect(() => {
service.createUser('not-an-email', 'Bob');
}).toThrow('Invalid email address');
});
});
describe('assignRole', () => {
it('should append a role to the user', () => {
const user: User = service.createUser('charlie@example.com', 'Charlie');
const originalRoleCount = user.roles.length;
const updatedUser: User = service.assignRole(user.id, Role.Admin);
expect(updatedUser.roles).toHaveLength(originalRoleCount + 1);
expect(updatedUser.roles).toContain(Role.Admin);
expect(updatedUser.roles).toContain(Role.Viewer);
});
it('should throw when user does not exist', () => {
expect(() => {
service.assignRole('nonexistent-id', Role.Editor);
}).toThrow('User with id nonexistent-id not found');
});
});
describe('getUserById', () => {
it('should return undefined for unknown user', () => {
const result = service.getUserById('nonexistent');
// TypeScript infers result as User | undefined
expect(result).toBeUndefined();
});
});
});
Notice that every variable declaration is typed — const user: User, const updatedUser: User. This isn't strictly necessary since TypeScript can infer these types from return values, but explicitly annotating test variables provides a second layer of verification: if the createUser method accidentally returns something incompatible with User, the test file itself fails to compile.
Strongly Typed Mocking
Mocking is where type safety truly shines. Untyped mocks drift from their interfaces over time, leading to tests that pass against stale mock shapes while the real implementation breaks. Here's how to keep mocks honest.
Typed Manual Mocks
// src/services/email-service.ts
export interface EmailOptions {
to: string;
subject: string;
body: string;
cc?: string[];
bcc?: string[];
}
export class EmailService {
async sendEmail(options: EmailOptions): Promise<{ messageId: string; timestamp: Date }> {
// Real implementation connects to SMTP server
const messageId = `msg_${Date.now()}`;
return { messageId, timestamp: new Date() };
}
async sendBulk(recipients: string[], template: string): Promise<number> {
// Real implementation sends bulk emails
return recipients.length;
}
}
// src/services/notification-service.ts
import { EmailService } from './email-service';
export class NotificationService {
constructor(private emailService: EmailService) {}
async notifyUser(userId: string, message: string): Promise<boolean> {
// Fetches user email, then sends via EmailService
return true;
}
}
Creating a Strictly Typed Mock
Rather than using jest.fn() without type parameters, constrain your mocks to the actual interface:
// tests/services/notification-service.test.ts
import { NotificationService } from '../../src/services/notification-service';
import { EmailService, EmailOptions } from '../../src/services/email-service';
// Create a typed mock that enforces the EmailService shape
const mockEmailService: jest.Mocked<EmailService> = {
sendEmail: jest.fn(),
sendBulk: jest.fn(),
} as jest.Mocked<EmailService>;
// Or, use the class-based approach with partial mocking
jest.mock('../../src/services/email-service', () => {
const actual = jest.requireActual('../../src/services/email-service');
return {
...actual,
EmailService: jest.fn().mockImplementation(() => ({
sendEmail: jest.fn<Promise<{ messageId: string; timestamp: Date }>, [EmailOptions]>()
.mockResolvedValue({ messageId: 'test-id', timestamp: new Date() }),
sendBulk: jest.fn<Promise<number>, [string[], string]>()
.mockResolvedValue(42),
})),
};
});
describe('NotificationService', () => {
let notificationService: NotificationService;
let emailService: jest.Mocked<EmailService>;
beforeEach(() => {
// TypeScript enforces that the mock satisfies EmailService
emailService = new EmailService() as jest.Mocked<EmailService>;
notificationService = new NotificationService(emailService);
});
it('should call sendEmail with correct options', async () => {
// Arrange
const expectedOptions: EmailOptions = {
to: 'user@example.com',
subject: 'Notification',
body: 'You have a new message',
};
emailService.sendEmail.mockResolvedValue({
messageId: 'msg_123',
timestamp: new Date('2024-01-01'),
});
// Act
await notificationService.notifyUser('user-1', 'You have a new message');
// Assert - typed mock tracks calls with full type info
expect(emailService.sendEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendEmail).toHaveBeenCalledWith(
expect.objectContaining<Partial<EmailOptions>>({
to: 'user@example.com',
})
);
});
it('should handle sendEmail failure gracefully', async () => {
// TypeScript enforces the rejection type
emailService.sendEmail.mockRejectedValue(new Error('SMTP timeout'));
// The mock's resolved/rejected types are checked
await expect(
notificationService.notifyUser('user-1', 'Hello')
).rejects.toThrow('SMTP timeout');
});
});
The jest.Mocked<T> utility type from @types/jest wraps all methods of a class in jest.Mock types, giving you proper type inference on mock implementations. When you call mockResolvedValue, TypeScript checks that the value you provide actually matches the return type of the original method.
Typed Snapshot Testing
Snapshot tests benefit enormously from TypeScript. Without types, a snapshot mismatch just shows you two large blobs of JSON. With types, your IDE tells you exactly which properties changed and whether they're valid.
// tests/components/user-profile.test.ts
import { User, Role } from '../../src/models/user';
interface UserProfileProps {
user: User;
editable: boolean;
onSave: (user: User) => void;
}
function renderUserProfile(props: UserProfileProps): Record<string, unknown> {
// Simplified render output for snapshot testing
return {
id: props.user.id,
displayName: props.user.displayName,
roles: props.user.roles,
isEditable: props.editable,
hasSaveHandler: typeof props.onSave === 'function',
};
}
describe('UserProfile snapshots', () => {
it('should match snapshot for admin user', () => {
const adminUser: User = {
id: 'user_admin_001',
email: 'admin@system.com',
displayName: 'System Admin',
createdAt: new Date('2023-06-15T10:00:00Z'),
roles: [Role.Admin, Role.Editor],
};
const output = renderUserProfile({
user: adminUser,
editable: true,
onSave: jest.fn<void, [User]>(), // typed mock for callback
});
// Snapshot is generated with full type context
expect(output).toMatchSnapshot();
});
});
When this snapshot is reviewed in a pull request, the reviewer can navigate to the UserProfileProps interface and immediately understand the contract being tested.
Parameterized Tests with Types
Jest's it.each and describe.each become dramatically safer when the table data is typed:
// tests/services/user-validation.test.ts
import { User, Role } from '../../src/models/user';
interface ValidationTestCase {
description: string;
input: Partial<User>;
expectedValid: boolean;
}
// Strongly typed table of test cases
const validationCases: ValidationTestCase[] = [
{
description: 'complete user should be valid',
input: {
id: 'user_1',
email: 'valid@email.com',
displayName: 'Alice',
createdAt: new Date(),
roles: [Role.Viewer],
},
expectedValid: true,
},
{
description: 'user without email should be invalid',
input: {
id: 'user_2',
displayName: 'Bob',
},
expectedValid: false,
},
{
description: 'user with empty roles should be invalid',
input: {
id: 'user_3',
email: 'charlie@test.com',
displayName: 'Charlie',
roles: [],
},
expectedValid: false,
},
];
function validateUser(user: Partial<User>): boolean {
if (!user.email || !user.displayName) return false;
if (!user.roles || user.roles.length === 0) return false;
return true;
}
describe('validateUser - parameterized', () => {
it.each(validationCases)(
'$description',
({ input, expectedValid }: ValidationTestCase) => {
const result = validateUser(input);
expect(result).toBe(expectedValid);
}
);
});
The destructured { input, expectedValid } is fully typed, so renaming a property in the ValidationTestCase interface immediately flags this test as broken. Without types, a typo like expecteValid would silently pass undefined and potentially mask real failures.
Advanced: Typed Custom Matchers
Jest allows you to extend the expect function with custom matchers. In TypeScript, you must augment the jest.Matchers interface so your custom matchers are recognized by the type checker.
// tests/matchers.d.ts
import { User } from '../src/models/user';
// Augment Jest's global matcher interface
declare global {
namespace jest {
interface Matchers<R> {
toBeValidUser(): R;
toHaveRole(role: import('../src/models/user').Role): R;
}
}
}
// tests/matchers/user-matchers.ts
import { User, Role } from '../../src/models/user';
export function toBeValidUser(this: jest.MatcherUtils, received: User): jest.CustomMatcherResult {
const hasValidId = typeof received.id === 'string' && received.id.length > 0;
const hasValidEmail = received.email.includes('@');
const hasDisplayName = typeof received.displayName === 'string' && received.displayName.length > 0;
const hasRoles = Array.isArray(received.roles) && received.roles.length > 0;
const pass = hasValidId && hasValidEmail && hasDisplayName && hasRoles;
return {
pass,
message: pass
? () => `Expected ${this.utils.printReceived(received)} not to be a valid user`
: () => `Expected ${this.utils.printReceived(received)} to be a valid user with all required fields`,
};
}
export function toHaveRole(
this: jest.MatcherUtils,
received: User,
expectedRole: Role
): jest.CustomMatcherResult {
const pass = received.roles.includes(expectedRole);
return {
pass,
message: pass
? () => `Expected user ${received.displayName} not to have role ${expectedRole}`
: () => `Expected user ${received.displayName} to have role ${expectedRole}, but found roles: ${received.roles.join(', ')}`,
};
}
// tests/services/user-service-custom-matchers.test.ts
import { UserService } from '../../src/services/user-service';
import { Role } from '../../src/models/user';
import { toBeValidUser, toHaveRole } from '../matchers/user-matchers';
// Register custom matchers
expect.extend({ toBeValidUser, toHaveRole });
describe('UserService with custom matchers', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
it('should produce a valid user with custom matcher', () => {
const user = service.createUser('test@example.com', 'Test User');
// TypeScript now recognizes this custom matcher
expect(user).toBeValidUser();
});
it('should assign role and verify with custom matcher', () => {
const user = service.createUser('editor@example.com', 'Editor User');
const updated = service.assignRole(user.id, Role.Editor);
expect(updated).toHaveRole(Role.Editor);
expect(updated).toHaveRole(Role.Viewer);
});
});
The triple slash reference or the declare global block in a .d.ts file is essential. Without it, TypeScript reports an error that toBeValidUser doesn't exist on Matchers. This pattern keeps your custom matchers as first-class citizens in the type system.
Best Practices for Jest with TypeScript
1. Use ts-jest, Not Babel, for TypeScript Transformation
Babel strips types without checking them. ts-jest performs full type-checking during test execution, catching mismatches immediately. The performance difference is negligible with modern caching, and the safety gain is substantial.
2. Explicitly Type Mock Objects
Always annotate mock objects with their interface or use jest.Mocked<T>. A bare jest.fn() returns any when untyped, which cascades through your test and nullifies type safety. For example:
// Bad: untyped mock
const mockFn = jest.fn(); // returns any
// Good: typed mock
const mockFn = jest.fn<Promise<User>, [string, Role]>(); // fully typed signature
3. Keep Test Fixtures in Separate, Typed Files
Create dedicated fixture files with explicit type annotations:
// tests/fixtures/users.ts
import { User, Role } from '../../src/models/user';
export const sampleAdmin: User = {
id: 'fixture_admin_01',
email: 'admin@fixture.test',
displayName: 'Fixture Admin',
createdAt: new Date('2023-01-01T00:00:00Z'),
roles: [Role.Admin, Role.Editor, Role.Viewer],
};
export const sampleViewer: User = {
id: 'fixture_viewer_01',
email: 'viewer@fixture.test',
displayName: 'Fixture Viewer',
createdAt: new Date('2023-01-01T00:00:00Z'),
roles: [Role.Viewer],
};
export const createUserInputs: Array<{ email: string; displayName: string }> = [
{ email: 'user1@test.com', displayName: 'User One' },
{ email: 'user2@test.com', displayName: 'User Two' },
];
This prevents fixture drift — if the User interface gains a required avatarUrl field, the fixture file fails to compile, forcing you to update test data across the entire suite.
4. Avoid Type Assertions in Tests
Type assertions (as) bypass the compiler's checks. In production code they're occasionally necessary, but in tests they mask real problems. Prefer explicit type annotations on variables instead:
// Avoid this in tests
const user = service.createUser('a@b.com', 'Alice') as User & { extraField: string };
// Prefer this — if createUser returns something unexpected, the test fails to compile
const user: User = service.createUser('a@b.com', 'Alice');
5. Configure Strict Compiler Options for Tests
Use a separate tsconfig.test.json that extends your base configuration with stricter settings:
// tsconfig.test.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
Then reference it in your Jest configuration:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
},
},
};
export default config;
6. Type Your Test Helpers and Utilities
Every utility function you write for tests should have explicit parameter and return types. This includes setup functions, factory functions, and assertion helpers:
// tests/helpers/setup-database.ts
import { User, Role } from '../../src/models/user';
export function seedDatabase(users: User[]): void {
// Insert users into test database
}
// Explicit return type ensures consumers know what they're getting
export function createTestUser(overrides?: Partial<User>): User {
const defaults: User = {
id: `test_${Date.now()}`,
email: 'default@test.com',
displayName: 'Default Test User',
createdAt: new Date(),
roles: [Role.Viewer],
};
return { ...defaults, ...overrides };
}
7. Use Discriminated Unions for Test Scenarios
When testing state machines or complex workflows, model your test scenarios with discriminated unions so TypeScript can verify exhaustiveness:
// tests/services/order-workflow.test.ts
type OrderState =
| { status: 'pending'; cart: string[] }
| { status: 'confirmed'; cart: string[]; confirmationId: string }
| { status: 'shipped'; trackingNumber: string }
| { status: 'delivered'; receivedBy: string };
type OrderAction =
| { type: 'CONFIRM'; confirmationId: string }
| { type: 'SHIP'; trackingNumber: string }
| { type: 'DELIVER'; receivedBy: string };
function processOrder(state: OrderState, action: OrderAction): OrderState {
switch (state.status) {
case 'pending':
if (action.type === 'CONFIRM') {
return { status: 'confirmed', cart: state.cart, confirmationId: action.confirmationId };
}
break;
case 'confirmed':
if (action.type === 'SHIP') {
return { status: 'shipped', trackingNumber: action.trackingNumber };
}
break;
case 'shipped':
if (action.type === 'DELIVER') {
return { status: 'delivered', receivedBy: action.receivedBy };
}
break;
}
return state;
}
describe('Order state machine', () => {
it('should transition from pending to confirmed', () => {
const initialState: OrderState = { status: 'pending', cart: ['item1'] };
const action: OrderAction = { type: 'CONFIRM', confirmationId: 'conf_123' };
const nextState = processOrder(initialState, action);
// TypeScript narrows the type based on the status check
expect(nextState.status).toBe('confirmed');
if (nextState.status === 'confirmed') {
// confirmationId is accessible and typed as string
expect(nextState.confirmationId).toBe('conf_123');
}
});
});
8. Leverage jest.mocked() for Module Mocks
When using jest.mock() on an entire module, use the jest.mocked() helper to cast the imported module to its mocked equivalent. This preserves types on deeply nested module objects:
// tests/services/report-service.test.ts
import { ReportGenerator } from '../../src/services/report-generator';
import { DataFetcher } from '../../src/services/data-fetcher';
jest.mock('../../src/services/data-fetcher');
describe('ReportGenerator', () => {
let reportGenerator: ReportGenerator;
let mockedDataFetcher: jest.Mocked<DataFetcher>;
beforeEach(() => {
// jest.mocked() wraps the constructor and all methods with jest.Mock types
mockedDataFetcher = jest.mocked(DataFetcher, { shallow: false });
reportGenerator = new ReportGenerator(mockedDataFetcher as unknown as DataFetcher);
});
it('should fetch data before generating report', async () => {
mockedDataFetcher.fetch.mockResolvedValue({
rows: [{ id: 1, value: 'test' }],
total: 1,
});
const report = await reportGenerator.generate('monthly');
expect(mockedDataFetcher.fetch).toHaveBeenCalledTimes(1);
expect(report).toBeDefined();
});
});
Conclusion
Jest and TypeScript together form a testing stack that catches errors at every possible stage — from the moment you write the test in your IDE, through compilation, and finally at runtime. Strongly typed tests are not an academic exercise; they are a practical investment that pays dividends in refactoring velocity, onboarding speed, and overall codebase confidence. By annotating your mocks, fixtures, parameterized test tables, and custom matchers with precise types, you transform your test suite from a safety net into an executable specification. The practices outlined here — using ts-jest with strict configuration, avoiding type assertions in tests, maintaining typed fixture files, and augmenting Jest's global matcher interfaces — will help you build a test suite that evolves gracefully alongside your production code rather than becoming a maintenance burden. Start with explicit type annotations on every mock and test variable today, and watch how quickly TypeScript becomes your most valuable testing ally.