Explain the difference between type and interface. When would you choose one over the other?
Answer:
Both type and interface are used to define object shapes in TypeScript, but they have important differences:
Key Differences:
- Declaration Merging:
// Interface - supports declaration merging
interface User {
name: string;
}
interface User {
age: number;
}
// Result: User has both name and age
const user: User = { name: 'John', age: 30 };
// Type - does NOT support declaration merging
type UserType = {
name: string;
}
// This would cause an error
type UserType = {
age: number;
}
- Extensibility:
// Interface - uses 'extends'
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Type - uses intersection (&)
type AnimalType = {
name: string;
}
type DogType = AnimalType & {
breed: string;
}
- Union Types:
// Type - supports union types
type Status = 'loading' | 'success' | 'error';
// Interface - cannot represent union types directly
interface StatusInterface {
// Cannot represent 'loading' | 'success' | 'error'
}
- Computed Properties:
// Type - supports computed/mapped properties
type Keys = 'name' | 'age';
type User = {
[K in Keys]: string;
}
// Interface - limited support for computed properties
interface UserInterface {
[key: string]: any; // Only index signatures
}
- Primitive Types:
// Type - can alias primitive types
type ID = string | number;
type Status = 'active' | 'inactive';
// Interface - cannot alias primitives
interface IDInterface = string; // Error!
When to Use Each:
Use interface when:
- Defining object shapes that might be extended
- Working with classes (implements)
- You need declaration merging
- Creating public APIs that others might extend
// Good for interfaces
interface ApiResponse {
data: any;
status: number;
}
interface UserResponse extends ApiResponse {
data: User;
}
class UserService implements UserResponse {
data: User;
status: number;
}
Use type when:
- Creating union types
- Aliasing primitive types
- Creating complex mapped types
- You need computed properties
- Creating utility types
// Good for types
type Theme = 'light' | 'dark';
type EventHandler<T> = (event: T) => void;
type Partial<T> = {
[P in keyof T]?: T[P];
}
// Complex utility type
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
}
Performance Considerations:
- Interfaces are slightly faster to compile
- Types can be more memory intensive for complex unions
- Both are erased at runtime (no performance difference)
Best Practices:
- Use
interfacefor object shapes that represent contracts - Use
typefor unions, primitives, and complex transformations - Be consistent within your codebase
- Prefer
interfacefor public APIs - Use
typefor internal utilities and transformations
What are generics and why are they useful? Provide an example of a generic function that demonstrates type safety.
Answer: Generics allow you to create reusable components that work with multiple types while maintaining type safety. They act as placeholders for types that will be specified later.
Why Generics Are Useful:
- Type Safety: Catch errors at compile time
- Code Reusability: Write once, use with multiple types
- IntelliSense: Better IDE support and autocomplete
- Flexibility: Work with any type while maintaining constraints
Basic Generic Function:
// Without generics - loses type information
function identity(arg: any): any {
return arg;
}
// With generics - preserves type information
function identity<T>(arg: T): T {
return arg;
}
const stringResult = identity<string>("hello"); // Type: string
const numberResult = identity<number>(42); // Type: number
const inferredResult = identity("world"); // Type: "world" (inferred)
Generic Constraints:
// Constraint: T must have a length property
function logLength<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength("hello"); // OK - string has length
logLength([1, 2, 3]); // OK - array has length
logLength(42); // Error - number doesn't have length
Multiple Type Parameters:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge(
{ name: "John" },
{ age: 30 }
); // Type: { name: string } & { age: number }
Generic Classes:
class Container<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T | undefined {
return this.items[index];
}
getAll(): T[] {
return [...this.items];
}
}
const stringContainer = new Container<string>();
stringContainer.add("hello");
stringContainer.add("world");
const numberContainer = new Container<number>();
numberContainer.add(1);
numberContainer.add(2);
Advanced Generic Patterns:
// Conditional types
type ApiResponse<T> = T extends string
? { message: T }
: { data: T };
// Mapped types with generics
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Generic utility functions
function createAsyncHandler<T, R>(
handler: (data: T) => Promise<R>
): (data: T) => Promise<R> {
return async (data: T) => {
try {
return await handler(data);
} catch (error) {
console.error('Handler error:', error);
throw error;
}
};
}
Coding Challenge: Create a type-safe function that deep clones an object while preserving all type information.
Answer:
// Basic deep clone with type preservation
function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T;
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T;
}
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags) as T;
}
if (obj instanceof Map) {
const clonedMap = new Map();
for (const [key, value] of obj) {
clonedMap.set(deepClone(key), deepClone(value));
}
return clonedMap as T;
}
if (obj instanceof Set) {
const clonedSet = new Set();
for (const value of obj) {
clonedSet.add(deepClone(value));
}
return clonedSet as T;
}
// Handle plain objects
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
// Advanced version with better type inference and error handling
type DeepCloneable =
| string
| number
| boolean
| null
| undefined
| Date
| RegExp
| Map<any, any>
| Set<any>
| Array<any>
| { [key: string]: any };
function advancedDeepClone<T extends DeepCloneable>(obj: T): T {
// Handle primitives and null/undefined
if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj;
}
// Handle Date objects
if (obj instanceof Date) {
return new Date(obj.getTime()) as T;
}
// Handle RegExp objects
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags) as T;
}
// Handle Map objects
if (obj instanceof Map) {
const clonedMap = new Map();
for (const [key, value] of obj) {
clonedMap.set(advancedDeepClone(key), advancedDeepClone(value));
}
return clonedMap as T;
}
// Handle Set objects
if (obj instanceof Set) {
const clonedSet = new Set();
for (const value of obj) {
clonedSet.add(advancedDeepClone(value));
}
return clonedSet as T;
}
// Handle Arrays
if (Array.isArray(obj)) {
return obj.map(item => advancedDeepClone(item)) as T;
}
// Handle plain objects
const clonedObj = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = advancedDeepClone(obj[key]);
}
}
return clonedObj;
}
// Usage examples
interface User {
id: number;
name: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
tags: string[];
createdAt: Date;
}
const originalUser: User = {
id: 1,
name: 'John Doe',
preferences: {
theme: 'dark',
notifications: true
},
tags: ['developer', 'typescript'],
createdAt: new Date('2023-01-01')
};
const clonedUser = deepClone(originalUser);
// clonedUser has the exact same type as originalUser
// Modifying clonedUser won't affect originalUser
// Test with complex nested structures
const complexObject = {
users: new Map([
['user1', { name: 'Alice', age: 30 }],
['user2', { name: 'Bob', age: 25 }]
]),
settings: new Set(['feature1', 'feature2']),
metadata: {
version: '1.0.0',
lastUpdated: new Date(),
config: {
apiUrl: 'https://api.example.com',
timeout: 5000
}
}
};
const clonedComplex = advancedDeepClone(complexObject);
// All nested objects, Maps, Sets, and Dates are properly cloned
Key Benefits of This Implementation:
- Type Safety: Returns the exact same type as input
- Deep Cloning: Handles nested objects, arrays, Maps, Sets
- Special Object Support: Properly clones Dates, RegExp, etc.
- Performance: Efficient handling of different object types
- Error Prevention: Avoids circular reference issues with proper checks
Explain how Partial, Pick, Omit, and Record utility types work. When would you use conditional types?
Answer: TypeScript provides several built-in utility types that help transform existing types. These are essential for creating flexible and reusable type definitions.
Core Utility Types:
- Partial<T> - Makes all properties optional:
interface User {
id: number;
name: string;
email: string;
age: number;
}
type PartialUser = Partial<User>;
// Equivalent to:
// {
// id?: number;
// name?: string;
// email?: string;
// age?: number;
// }
// Usage in update functions
function updateUser(id: number, updates: Partial<User>): void {
// Only some fields need to be provided
console.log(`Updating user ${id} with:`, updates);
}
updateUser(1, { name: 'John' }); // OK - only name provided
updateUser(1, { name: 'John', age: 30 }); // OK - multiple fields
- Pick<T, K> - Selects specific properties:
type UserSummary = Pick<User, 'id' | 'name'>;
// Equivalent to:
// {
// id: number;
// name: string;
// }
// Usage for API responses
function getUserSummary(id: number): UserSummary {
const user = getUserById(id);
return {
id: user.id,
name: user.name
};
}
- Omit<T, K> - Excludes specific properties:
type UserWithoutId = Omit<User, 'id'>;
// Equivalent to:
// {
// name: string;
// email: string;
// age: number;
// }
// Usage for creating new users (without ID)
function createUser(userData: Omit<User, 'id'>): User {
return {
id: generateId(),
...userData
};
}
- Record<K, V> - Creates object type with specific keys and values:
type Status = 'loading' | 'success' | 'error';
type StatusMessages = Record<Status, string>;
// Equivalent to:
// {
// loading: string;
// success: string;
// error: string;
// }
const messages: StatusMessages = {
loading: 'Please wait...',
success: 'Operation completed!',
error: 'Something went wrong!'
};
// Dynamic object creation
type UserRoles = Record<string, string[]>;
const userRoles: UserRoles = {
admin: ['read', 'write', 'delete'],
user: ['read'],
guest: []
};
Advanced Utility Types:
- Required<T> - Makes all properties required:
interface Config {
apiUrl?: string;
timeout?: number;
retries?: number;
}
type RequiredConfig = Required<Config>;
// All properties are now required
- Readonly<T> - Makes all properties readonly:
type ReadonlyUser = Readonly<User>;
// All properties are now readonly
- Exclude<T, U> - Excludes types from union:
type AllColors = 'red' | 'green' | 'blue' | 'yellow';
type PrimaryColors = Exclude<AllColors, 'yellow'>; // 'red' | 'green' | 'blue'
- Extract<T, U> - Extracts types from union:
type MixedTypes = string | number | boolean | Date;
type PrimitiveTypes = Extract<MixedTypes, string | number | boolean>;
// 'string' | 'number' | 'boolean'
Custom Utility Types:
// Deep partial - makes nested properties optional too
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Non-nullable - removes null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
// Function properties only
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
// Optional properties only
type OptionalPropertyNames<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
Conditional Types:
Conditional types allow you to create types that depend on other types. They use the syntax T extends U ? X : Y.
When to Use Conditional Types:
- API Response Handling:
type ApiResponse<T> = T extends string
? { message: T }
: { data: T };
type StringResponse = ApiResponse<string>; // { message: string }
type ObjectResponse = ApiResponse<User>; // { data: User }
- Function Overloading:
type OverloadedFunction<T> = T extends string
? (input: T) => string
: T extends number
? (input: T) => number
: (input: T) => T;
const process: OverloadedFunction<string> = (input) => input.toUpperCase();
const processNumber: OverloadedFunction<number> = (input) => input * 2;
- Array vs Non-Array Handling:
type Flatten<T> = T extends (infer U)[] ? U : T;
type StringArray = Flatten<string[]>; // string
type StringType = Flatten<string>; // string
- Infer Keyword - Extract Types:
// Extract return type from function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract parameter types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
// Usage
type MyFunction = (a: string, b: number) => boolean;
type MyReturnType = ReturnType<MyFunction>; // boolean
type MyParameters = Parameters<MyFunction>; // [string, number]
- Recursive Conditional Types:
// Deep readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Deep required
type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};
// JSON serializable types
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
type JSONSerializable<T> = T extends JSONValue ? T : never;
Practical Examples:
// Form handling with partial updates
interface UserForm {
name: string;
email: string;
age: number;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
type UserFormUpdate = DeepPartial<UserForm>;
function updateUserForm(updates: UserFormUpdate): void {
// Can update any nested property
console.log('Updating form with:', updates);
}
updateUserForm({
name: 'John',
preferences: {
theme: 'dark'
// notifications can be omitted
}
});
// Event handler types
type EventMap = {
click: MouseEvent;
keydown: KeyboardEvent;
load: Event;
};
type EventHandler<T extends keyof EventMap> = (event: EventMap[T]) => void;
function addEventListener<T extends keyof EventMap>(
event: T,
handler: EventHandler<T>
): void {
// Type-safe event handling
}
addEventListener('click', (event) => {
// event is typed as MouseEvent
console.log(event.clientX, event.clientY);
});
addEventListener('keydown', (event) => {
// event is typed as KeyboardEvent
console.log(event.key, event.code);
});
Best Practices:
- Use utility types to create variations of existing types
- Combine utility types for complex transformations
- Use conditional types for type-dependent logic
- Leverage
inferto extract types from complex structures - Create custom utility types for domain-specific needs
- Use conditional types sparingly - they can make code hard to understand
What are type guards? Implement a custom type guard function and explain how TypeScript narrows types.
Answer: Type guards are expressions that perform runtime checks to narrow down the type of a variable within a specific scope. They help TypeScript understand the actual type of a value at runtime, enabling better type safety and IntelliSense.
How Type Narrowing Works:
TypeScript uses control flow analysis to narrow types based on certain conditions. When a type guard returns true, TypeScript narrows the type in the subsequent code block.
Built-in Type Guards:
- typeof Guards:
function processValue(value: string | number) {
if (typeof value === 'string') {
// TypeScript knows value is string here
console.log(value.toUpperCase()); // OK
console.log(value.length); // OK
} else {
// TypeScript knows value is number here
console.log(value.toFixed(2)); // OK
console.log(value * 2); // OK
}
}
- instanceof Guards:
function processError(error: Error | string) {
if (error instanceof Error) {
// TypeScript knows error is Error here
console.log(error.message);
console.log(error.stack);
} else {
// TypeScript knows error is string here
console.log(error.toUpperCase());
}
}
- in Guards:
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish) {
if ('fly' in animal) {
// TypeScript knows animal is Bird
animal.fly();
} else {
// TypeScript knows animal is Fish
animal.swim();
}
}
- Equality Guards:
function processValue(value: string | null | undefined) {
if (value === null) {
// TypeScript knows value is null
console.log('Value is null');
} else if (value === undefined) {
// TypeScript knows value is undefined
console.log('Value is undefined');
} else {
// TypeScript knows value is string
console.log(value.toUpperCase());
}
}
Custom Type Guards:
Type guards are functions that return a type predicate: value is Type.
// Basic custom type guard
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processUnknown(value: unknown) {
if (isString(value)) {
// TypeScript knows value is string
console.log(value.toUpperCase());
}
}
// More complex type guard
interface User {
id: number;
name: string;
email: string;
}
interface Admin {
id: number;
name: string;
permissions: string[];
}
function isUser(obj: any): obj is User {
return obj &&
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
!('permissions' in obj);
}
function isAdmin(obj: any): obj is Admin {
return obj &&
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
Array.isArray(obj.permissions);
}
function processPerson(person: User | Admin) {
if (isUser(person)) {
// TypeScript knows person is User
console.log(`User email: ${person.email}`);
} else if (isAdmin(person)) {
// TypeScript knows person is Admin
console.log(`Admin permissions: ${person.permissions.join(', ')}`);
}
}
Advanced Type Guard Patterns:
- Discriminated Union Guards:
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: any;
}
interface ErrorState {
status: 'error';
error: string;
}
type AppState = LoadingState | SuccessState | ErrorState;
function handleState(state: AppState) {
switch (state.status) {
case 'loading':
// TypeScript knows state is LoadingState
console.log('Loading...');
break;
case 'success':
// TypeScript knows state is SuccessState
console.log('Data:', state.data);
break;
case 'error':
// TypeScript knows state is ErrorState
console.log('Error:', state.error);
break;
}
}
- Array Type Guards:
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string');
}
function processArray(value: unknown) {
if (isStringArray(value)) {
// TypeScript knows value is string[]
value.forEach(str => console.log(str.toUpperCase()));
}
}
- Generic Type Guards:
function isOfType<T>(
value: unknown,
type: string
): value is T {
return typeof value === type;
}
function isNumber(value: unknown): value is number {
return isOfType<number>(value, 'number');
}
function isBoolean(value: unknown): value is boolean {
return isOfType<boolean>(value, 'boolean');
}
- Complex Object Validation:
interface ApiResponse {
success: boolean;
data?: any;
error?: string;
}
function isApiResponse(obj: unknown): obj is ApiResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'success' in obj &&
typeof (obj as any).success === 'boolean'
);
}
function handleApiResponse(response: unknown) {
if (isApiResponse(response)) {
if (response.success) {
// TypeScript knows response.data exists
console.log('Data:', response.data);
} else {
// TypeScript knows response.error exists
console.log('Error:', response.error);
}
}
}
- Branded Types with Type Guards:
// Branded types for additional type safety
type UserId = number & { readonly __brand: 'UserId' };
type ProductId = number & { readonly __brand: 'ProductId' };
function createUserId(id: number): UserId {
if (id <= 0) {
throw new Error('Invalid user ID');
}
return id as UserId;
}
function createProductId(id: number): ProductId {
if (id <= 0) {
throw new Error('Invalid product ID');
}
return id as ProductId;
}
function isUserId(value: unknown): value is UserId {
return typeof value === 'number' && value > 0;
}
function processId(id: UserId | ProductId) {
if (isUserId(id)) {
// TypeScript knows id is UserId
console.log('Processing user ID:', id);
} else {
// TypeScript knows id is ProductId
console.log('Processing product ID:', id);
}
}
Type Guard Best Practices:
- Use Type Predicates:
// Good - uses type predicate
function isString(value: unknown): value is string {
return typeof value === 'string';
}
// Avoid - doesn't narrow types
function isString(value: unknown): boolean {
return typeof value === 'string';
}
- Combine Multiple Guards:
function isValidUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj &&
typeof (obj as any).id === 'number' &&
typeof (obj as any).name === 'string' &&
typeof (obj as any).email === 'string'
);
}
- Use Assertion Functions:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Expected string');
}
}
function processValue(value: unknown) {
assertIsString(value);
// TypeScript knows value is string after assertion
console.log(value.toUpperCase());
}
- Create Reusable Guard Libraries:
// Type guard utilities
export const TypeGuards = {
isString: (value: unknown): value is string => typeof value === 'string',
isNumber: (value: unknown): value is number => typeof value === 'number',
isBoolean: (value: unknown): value is boolean => typeof value === 'boolean',
isObject: (value: unknown): value is object => typeof value === 'object' && value !== null,
isArray: (value: unknown): value is unknown[] => Array.isArray(value),
isFunction: (value: unknown): value is Function => typeof value === 'function',
isNull: (value: unknown): value is null => value === null,
isUndefined: (value: unknown): value is undefined => value === undefined,
isNullish: (value: unknown): value is null | undefined => value == null,
};
Common Pitfalls:
- Don't forget the type predicate:
// Wrong - doesn't narrow types
function isString(value: unknown): boolean {
return typeof value === 'string';
}
// Correct - narrows types
function isString(value: unknown): value is string {
return typeof value === 'string';
}
- Be careful with any:
// Avoid using any in type guards
function badGuard(value: any): value is User {
return value.name && value.email; // Too permissive
}
// Better - be specific
function goodGuard(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
'email' in value &&
typeof (value as any).name === 'string' &&
typeof (value as any).email === 'string'
);
}
Type guards are essential for working with dynamic data, API responses, and user input where types aren't known at compile time. They provide runtime type safety while maintaining TypeScript's compile-time benefits.
Explain mapped types and template literal types. Provide a practical example where these would be beneficial.
Answer: Mapped types and template literal types are advanced TypeScript features that allow you to create new types by transforming existing ones. They're powerful tools for creating reusable and flexible type definitions.
Mapped Types:
Mapped types allow you to create new types by iterating over the keys of an existing type and transforming them.
Basic Mapped Type Syntax:
type MappedType<T> = {
[K in keyof T]: T[K];
};
Common Mapped Type Patterns:
- Making All Properties Optional:
type Partial<T> = {
[P in keyof T]?: T[P];
};
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; }
- Making All Properties Required:
type Required<T> = {
[P in keyof T]-?: T[P];
};
interface Config {
apiUrl?: string;
timeout?: number;
}
type RequiredConfig = Required<Config>;
// { apiUrl: string; timeout: number; }
- Making All Properties Readonly:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }
- Transforming Property Types:
type Stringify<T> = {
[K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string; }
- Conditional Property Transformation:
type NonNullable<T> = {
[P in keyof T]: T[P] extends null | undefined ? never : T[P];
};
type NonNullableUser = NonNullable<{
id: number;
name: string | null;
email: string | undefined;
}>;
// { id: number; name: never; email: never; }
Advanced Mapped Type Patterns:
- Key Remapping:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string; }
- Filtering Properties:
type FunctionProperties<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
interface MixedObject {
name: string;
age: number;
greet: () => void;
calculate: (x: number) => number;
}
type FunctionsOnly = FunctionProperties<MixedObject>;
// { greet: () => void; calculate: (x: number) => number; }
- Conditional Key Mapping:
type ApiEndpoints<T> = {
[K in keyof T as T[K] extends { id: any } ? `get${Capitalize<string & K>}` : never]:
(id: T[K]['id']) => Promise<T[K]>;
};
interface ApiResources {
user: { id: number; name: string };
product: { id: string; title: string };
category: { name: string }; // No id property
}
type Endpoints = ApiEndpoints<ApiResources>;
// { getUser: (id: number) => Promise<{ id: number; name: string }>;
// getProduct: (id: string) => Promise<{ id: string; title: string }>; }
Template Literal Types:
Template literal types allow you to create string literal types by combining other types.
Basic Template Literal Syntax:
type TemplateLiteral = `prefix-${string}-suffix`;
Common Template Literal Patterns:
- String Concatenation:
type EventName = 'click' | 'hover' | 'focus';
type EventHandler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onHover' | 'onFocus'
type CSSProperty = 'margin' | 'padding' | 'border';
type CSSDirection = 'top' | 'right' | 'bottom' | 'left';
type CSSPropertyWithDirection = `${CSSProperty}-${CSSDirection}`;
// 'margin-top' | 'margin-right' | 'margin-bottom' | 'margin-left' |
// 'padding-top' | 'padding-right' | 'padding-bottom' | 'padding-left' |
// 'border-top' | 'border-right' | 'border-bottom' | 'border-left'
- API Route Generation:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'users' | 'posts' | 'comments';
type ApiRoute = `${HttpMethod} /api/${Resource}`;
// 'GET /api/users' | 'GET /api/posts' | 'GET /api/comments' |
// 'POST /api/users' | 'POST /api/posts' | 'POST /api/comments' | ...
- CSS Class Generation:
type Component = 'button' | 'input' | 'card';
type Variant = 'primary' | 'secondary' | 'danger';
type Size = 'sm' | 'md' | 'lg';
type CSSClass = `${Component}-${Variant}-${Size}`;
// 'button-primary-sm' | 'button-primary-md' | 'button-primary-lg' | ...
Advanced Template Literal Patterns:
- String Manipulation Utilities:
type Capitalize<S extends string> = S extends `${infer F}${infer R}`
? `${Uppercase<F>}${R}`
: S;
type Uncapitalize<S extends string> = S extends `${infer F}${infer R}`
? `${Lowercase<F>}${R}`
: S;
type CamelCase<S extends string> = S extends `${infer P1}-${infer P2}${infer P3}`
? `${P1}${Capitalize<`${P2}${P3}`>}`
: S;
type PascalCase<S extends string> = Capitalize<CamelCase<S>>;
type KebabCase<S extends string> = S extends `${infer C}${infer T}`
? T extends Uncapitalize<T>
? `${Uncapitalize<C>}${KebabCase<T>}`
: `${Uncapitalize<C>}-${KebabCase<Uncapitalize<T>>}`
: S;
- Path Parameter Extraction:
type ExtractParams<T extends string> = T extends `${string}:${infer P}/${infer R}`
? P | ExtractParams<R>
: T extends `${string}:${infer P}`
? P
: never;
type RouteParams = ExtractParams<'/users/:id/posts/:postId'>;
// 'id' | 'postId'
Practical Example: Form Builder System
Here's a comprehensive example that combines mapped types and template literal types to create a type-safe form builder:
// Base field types
type FieldType = 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'textarea';
// Field configuration
interface BaseField {
type: FieldType;
label: string;
required?: boolean;
placeholder?: string;
}
interface SelectField extends BaseField {
type: 'select';
options: Array<{ value: string; label: string }>;
}
interface CheckboxField extends BaseField {
type: 'checkbox';
defaultChecked?: boolean;
}
type Field = BaseField | SelectField | CheckboxField;
// Form schema definition
interface FormSchema {
[fieldName: string]: Field;
}
// Generate form data type from schema
type FormData<T extends FormSchema> = {
[K in keyof T]: T[K] extends { type: 'number' }
? number
: T[K] extends { type: 'checkbox' }
? boolean
: string;
};
// Generate validation rules type
type ValidationRules<T extends FormSchema> = {
[K in keyof T as `${string & K}Validation`]: {
required?: boolean;
minLength?: T[K] extends { type: 'text' | 'textarea' } ? number : never;
maxLength?: T[K] extends { type: 'text' | 'textarea' } ? number : never;
min?: T[K] extends { type: 'number' } ? number : never;
max?: T[K] extends { type: 'number' } ? number : never;
pattern?: T[K] extends { type: 'text' | 'email' } ? RegExp : never;
};
};
// Generate error messages type
type ErrorMessages<T extends FormSchema> = {
[K in keyof T as `${string & K}Error`]?: string;
};
// Generate form state type
type FormState<T extends FormSchema> = {
data: FormData<T>;
errors: ErrorMessages<T>;
touched: {
[K in keyof T]?: boolean;
};
isValid: boolean;
isSubmitting: boolean;
};
// Generate event handler types
type FormEventHandlers<T extends FormSchema> = {
[K in keyof T as `handle${Capitalize<string & K>}Change`]: (
value: FormData<T>[K]
) => void;
} & {
handleSubmit: (data: FormData<T>) => void | Promise<void>;
handleReset: () => void;
validateField: (fieldName: keyof T) => string | undefined;
};
// Example usage
const userFormSchema: FormSchema = {
firstName: {
type: 'text',
label: 'First Name',
required: true,
placeholder: 'Enter your first name'
},
lastName: {
type: 'text',
label: 'Last Name',
required: true,
placeholder: 'Enter your last name'
},
email: {
type: 'email',
label: 'Email Address',
required: true,
placeholder: 'Enter your email'
},
age: {
type: 'number',
label: 'Age',
required: false
},
country: {
type: 'select',
label: 'Country',
required: true,
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'uk', label: 'United Kingdom' }
]
},
newsletter: {
type: 'checkbox',
label: 'Subscribe to newsletter',
defaultChecked: false
}
};
// Generated types
type UserFormData = FormData<typeof userFormSchema>;
// {
// firstName: string;
// lastName: string;
// email: string;
// age: number;
// country: string;
// newsletter: boolean;
// }
type UserFormValidation = ValidationRules<typeof userFormSchema>;
// {
// firstNameValidation: { required?: boolean; minLength?: number; maxLength?: number; };
// lastNameValidation: { required?: boolean; minLength?: number; maxLength?: number; };
// emailValidation: { required?: boolean; pattern?: RegExp; };
// ageValidation: { required?: boolean; min?: number; max?: number; };
// countryValidation: { required?: boolean; };
// newsletterValidation: { required?: boolean; };
// }
type UserFormEventHandlers = FormEventHandlers<typeof userFormSchema>;
// {
// handleFirstNameChange: (value: string) => void;
// handleLastNameChange: (value: string) => void;
// handleEmailChange: (value: string) => void;
// handleAgeChange: (value: number) => void;
// handleCountryChange: (value: string) => void;
// handleNewsletterChange: (value: boolean) => void;
// handleSubmit: (data: UserFormData) => void | Promise<void>;
// handleReset: () => void;
// validateField: (fieldName: keyof typeof userFormSchema) => string | undefined;
// }
// Form builder implementation
class FormBuilder<T extends FormSchema> {
private schema: T;
private validationRules: ValidationRules<T>;
constructor(schema: T, validationRules: ValidationRules<T>) {
this.schema = schema;
this.validationRules = validationRules;
}
createFormState(): FormState<T> {
const data = {} as FormData<T>;
const errors = {} as ErrorMessages<T>;
const touched = {} as { [K in keyof T]?: boolean };
// Initialize form data with default values
for (const [fieldName, field] of Object.entries(this.schema)) {
if (field.type === 'checkbox') {
(data as any)[fieldName] = field.defaultChecked || false;
} else if (field.type === 'number') {
(data as any)[fieldName] = 0;
} else {
(data as any)[fieldName] = '';
}
}
return {
data,
errors,
touched,
isValid: false,
isSubmitting: false
};
}
validateField(fieldName: keyof T, value: any): string | undefined {
const field = this.schema[fieldName];
const rules = (this.validationRules as any)[`${String(fieldName)}Validation`];
if (rules?.required && (!value || value === '')) {
return `${field.label} is required`;
}
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
if (rules?.minLength && value && value.length < rules.minLength) {
return `${field.label} must be at least ${rules.minLength} characters`;
}
if (rules?.maxLength && value && value.length > rules.maxLength) {
return `${field.label} must be no more than ${rules.maxLength} characters`;
}
if (rules?.min && field.type === 'number' && value < rules.min) {
return `${field.label} must be at least ${rules.min}`;
}
if (rules?.max && field.type === 'number' && value > rules.max) {
return `${field.label} must be no more than ${rules.max}`;
}
return undefined;
}
}
// Usage example
const userFormValidation: UserFormValidation = {
firstNameValidation: { required: true, minLength: 2, maxLength: 50 },
lastNameValidation: { required: true, minLength: 2, maxLength: 50 },
emailValidation: { required: true },
ageValidation: { min: 0, max: 120 },
countryValidation: { required: true },
newsletterValidation: {}
};
const userFormBuilder = new FormBuilder(userFormSchema, userFormValidation);
const formState = userFormBuilder.createFormState();
// Type-safe form handling
const handleFirstNameChange = (value: string) => {
const error = userFormBuilder.validateField('firstName', value);
// Update form state...
};
const handleSubmit = (data: UserFormData) => {
console.log('Submitting form data:', data);
// Submit to API...
};
Benefits of This Approach:
- Type Safety: All form data, validation rules, and event handlers are fully typed
- IntelliSense: Full autocomplete support for all generated types
- Compile-time Validation: TypeScript catches errors at compile time
- Reusability: The form builder can work with any schema
- Maintainability: Changes to the schema automatically update all related types
- Consistency: Generated types ensure consistency across the application
This example demonstrates how mapped types and template literal types can be combined to create powerful, type-safe abstractions that would be impossible to achieve with traditional type definitions alone.