TypeScript Type System Fundamentals
TypeScript's type system catches bugs at compile time that would otherwise surface at runtime. Beyond error prevention, types serve as documentation - function signatures declare contracts that the compiler enforces. Invest in good types, and your code becomes self-documenting and resistant to regression.
Overview
This guide covers the foundational TypeScript type system: basic types, interfaces, type guards, generics, and built-in utility types. Master these concepts before advancing to mapped types, conditional types, and type-level programming in TypeScript Advanced Types.
Core Principles
- Strict Mode Always: Enable all strict flags for maximum safety
- Let TypeScript Infer: Avoid explicit types when inference is correct
- Prefer Interfaces for Objects: Use
typefor unions, intersections, and aliases - Use Type Guards for Narrowing: Let TypeScript know specific types through runtime checks
- Leverage Utility Types: Don't reinvent
Partial,Required,Pick,Omit
Basic Types
Primitive Types
// String, number, boolean
const accountId: string = 'ACC-123';
const balance: number = 1250.50;
const isActive: boolean = true;
// Symbol for unique identifiers
const PAYMENT_EVENT = Symbol('payment');
// BigInt for large numbers (integers only)
const transactionLimit: bigint = 9999999999999999999n;
// null and undefined (distinct in strict mode)
let optionalValue: string | null = null;
let uninitializedValue: string | undefined;
Type Inference
TypeScript infers types from context. Explicit annotations are unnecessary when inference is correct.
// GOOD: Let TypeScript infer
const amount = 100.50; // Inferred: number
const status = 'pending'; // Inferred: 'pending' (literal type)
const payments = []; // Inferred: never[] - add type annotation!
// GOOD: Explicit when needed
const payments: Payment[] = []; // Now TypeScript knows the element type
// BAD: Redundant type annotation
const count: number = 0; // TypeScript already knows it's a number
When to Add Explicit Types:
- Function parameters and return types (recommended for public APIs)
- Empty arrays or objects
- When inference produces a less specific type than you want
Literal Types
Literal types narrow types to specific values.
// String literal
type PaymentStatus = 'pending' | 'completed' | 'failed' | 'refunded';
// Number literal
type HttpSuccessCode = 200 | 201 | 204;
// Boolean literal
type Success = true;
type Failure = false;
// Const assertion preserves literal types
const config = {
apiVersion: 'v1',
timeout: 5000
} as const;
// Type: { readonly apiVersion: "v1"; readonly timeout: 5000 }
// Without 'as const':
const configMutable = {
apiVersion: 'v1',
timeout: 5000
};
// Type: { apiVersion: string; timeout: number }
Arrays and Tuples
Arrays
// Array syntax (prefer this)
const payments: Payment[] = [];
// Generic syntax (same thing)
const accounts: Array<Account> = [];
// Readonly array - prevents mutation
const immutableIds: readonly string[] = ['ACC-1', 'ACC-2'];
immutableIds.push('ACC-3'); // Error: Property 'push' does not exist
// Array methods preserve type information
const amounts = [100, 200, 300];
const doubled = amounts.map(a => a * 2); // number[]
const first = amounts.find(a => a > 150); // number | undefined
Tuples
Tuples are fixed-length arrays with specific types at each position.
// Basic tuple
type Coordinate = [number, number];
const point: Coordinate = [10, 20];
// Named tuple elements (for documentation)
type PaymentEntry = [id: string, amount: number, currency: string];
const entry: PaymentEntry = ['PAY-1', 100, 'USD'];
// Optional elements
type Response = [number, string, Error?];
const success: Response = [200, 'OK'];
const failure: Response = [500, 'Error', new Error('Failed')];
// Rest elements
type StringArray = [string, ...number[]];
const mixed: StringArray = ['header', 1, 2, 3, 4];
// Readonly tuple
const immutableTuple: readonly [string, number] = ['PAY-1', 100];
immutableTuple[0] = 'PAY-2'; // Error: Cannot assign to '0'
Interfaces and Type Aliases
Interfaces
Interfaces define the shape of objects. They can be extended and merged.
interface Payment {
id: string;
amount: number;
currency: string;
status: PaymentStatus;
createdAt: Date;
}
// Optional properties
interface PaymentRequest {
amount: number;
currency: string;
metadata?: Record<string, unknown>; // Optional
}
// Readonly properties
interface ImmutablePayment {
readonly id: string;
readonly amount: number;
readonly currency: string;
}
// Extending interfaces
interface DetailedPayment extends Payment {
description: string;
vendor: VendorInfo;
auditTrail: AuditEntry[];
}
// Multiple inheritance
interface PaymentWithFees extends Payment, Fees {
totalAmount: number;
}
Interface Merging (Declaration Merging)
Multiple interface declarations with the same name merge into one.
// First declaration
interface PaymentService {
process(payment: Payment): PaymentResult;
}
// Merges with first declaration
interface PaymentService {
refund(paymentId: string): RefundResult;
}
// Usage - has both methods
const service: PaymentService = {
process(payment) { /* ... */ },
refund(paymentId) { /* ... */ }
};
This is useful for extending library types without modifying source files.
Type Aliases
Type aliases create names for any type, including unions, intersections, and primitives.
// Primitive alias
type AccountId = string;
type Amount = number;
// Union type
type PaymentMethod = 'credit_card' | 'bank_transfer' | 'crypto';
// Intersection type (combines types)
type PaymentWithAudit = Payment & {
auditTrail: AuditEntry[];
};
// Function type
type PaymentProcessor = (payment: Payment) => Promise<PaymentResult>;
// Conditional type (advanced)
type NonNullablePayment = NonNullable<Payment | null>;
Interface vs Type: When to Use Which
| Use Case | Prefer | Reason |
|---|---|---|
| Object shapes | interface | Extensible, better error messages |
| Union types | type | Interfaces can't express unions |
| Intersection types | type | Cleaner syntax than extends |
| Primitive aliases | type | Interfaces can't alias primitives |
| Function types | type | More readable syntax |
| Mapped/conditional types | type | Interfaces can't use in or extends |
// GOOD: Interface for object shape
interface PaymentRepository {
findById(id: string): Payment | undefined;
save(payment: Payment): void;
}
// GOOD: Type for union
type PaymentResult = SuccessResult | FailureResult;
// GOOD: Type for function signature
type PaymentValidator = (payment: Payment) => ValidationResult;
Type Guards and Narrowing
Type guards are runtime checks that narrow types within a conditional block.
typeof Guards
function formatValue(value: string | number): string {
if (typeof value === 'string') {
// TypeScript knows: value is string
return value.toUpperCase();
}
// TypeScript knows: value is number
return value.toFixed(2);
}
instanceof Guards
class PaymentError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}
function handleError(error: Error) {
if (error instanceof PaymentError) {
// TypeScript knows: error is PaymentError
console.error(`Payment error ${error.code}: ${error.message}`);
} else {
console.error(`Unexpected error: ${error.message}`);
}
}
in Guards
interface SuccessResult {
success: true;
data: Payment;
}
interface FailureResult {
success: false;
error: string;
}
type PaymentResult = SuccessResult | FailureResult;
function handleResult(result: PaymentResult) {
if ('data' in result) {
// TypeScript knows: result is SuccessResult
console.log(result.data.id);
} else {
// TypeScript knows: result is FailureResult
console.error(result.error);
}
}
Custom Type Guard Functions
Create reusable type guards with type predicates (is syntax).
// Type predicate: returns true and narrows type
function isSuccessResult(result: PaymentResult): result is SuccessResult {
return result.success === true;
}
function processResult(result: PaymentResult) {
if (isSuccessResult(result)) {
// TypeScript knows: result is SuccessResult
processPayment(result.data);
} else {
// TypeScript knows: result is FailureResult
logError(result.error);
}
}
// Guard for unknown values (from external sources)
function isPayment(value: unknown): value is Payment {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'amount' in value &&
'currency' in value &&
typeof (value as Payment).id === 'string' &&
typeof (value as Payment).amount === 'number' &&
typeof (value as Payment).currency === 'string'
);
}
// Usage with API response
const apiResponse: unknown = await fetch('/payment').then(r => r.json());
if (isPayment(apiResponse)) {
// Safe to use as Payment
console.log(apiResponse.id);
}
Discriminated Unions
Discriminated unions use a common property (the discriminant) for narrowing.
type PaymentState =
| { status: 'idle' }
| { status: 'loading'; startedAt: Date }
| { status: 'success'; data: Payment }
| { status: 'error'; error: Error };
function renderState(state: PaymentState): string {
switch (state.status) {
case 'idle':
return 'Ready';
case 'loading':
// TypeScript knows: state has startedAt
return `Loading since ${state.startedAt.toISOString()}`;
case 'success':
// TypeScript knows: state has data
return `Payment ${state.data.id} completed`;
case 'error':
// TypeScript knows: state has error
return `Error: ${state.error.message}`;
}
}
Generics
Generics create reusable components that work with multiple types while maintaining type safety.
Basic Generics
// Generic function
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // Type: number
const str = identity('hello'); // Type: string
// Generic interface
interface Repository<T> {
findById(id: string): T | undefined;
findAll(): T[];
save(entity: T): void;
delete(id: string): void;
}
// Generic class
class PaymentRepository implements Repository<Payment> {
findById(id: string): Payment | undefined { /* ... */ }
findAll(): Payment[] { /* ... */ }
save(entity: Payment): void { /* ... */ }
delete(id: string): void { /* ... */ }
}
Generic Constraints
Constrain generics to types with specific properties.
// Constraint: T must have id property
interface Identifiable {
id: string;
}
function findById<T extends Identifiable>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Works with any type that has id
const payment = findById(payments, 'PAY-1'); // Payment | undefined
const account = findById(accounts, 'ACC-1'); // Account | undefined
// Constraint: K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const payment = { id: 'PAY-1', amount: 100 };
const id = getProperty(payment, 'id'); // string
const amount = getProperty(payment, 'amount'); // number
const invalid = getProperty(payment, 'foo'); // Error: 'foo' not in Payment
Multiple Type Parameters
function map<T, U>(array: T[], transform: (item: T) => U): U[] {
return array.map(transform);
}
const payments = [{ id: 'PAY-1', amount: 100 }];
const ids = map(payments, p => p.id); // string[]
// Swapping types
function swap<T, U>(tuple: [T, U]): [U, T] {
return [tuple[1], tuple[0]];
}
const swapped = swap(['hello', 42]); // [number, string]
Default Type Parameters
interface ApiResponse<T = unknown, E = Error> {
data?: T;
error?: E;
status: number;
}
// Uses defaults
const response1: ApiResponse = { status: 200 };
// Specify data type
const response2: ApiResponse<Payment> = {
data: { id: 'PAY-1', amount: 100, /* ... */ },
status: 200
};
// Specify both
const response3: ApiResponse<Payment, PaymentError> = {
error: new PaymentError('Failed'),
status: 400
};
Utility Types
TypeScript provides built-in utility types for common type transformations.
Partial<T>
Make all properties optional.
interface Payment {
id: string;
amount: number;
currency: string;
status: PaymentStatus;
}
// All properties optional - useful for updates
type PaymentUpdate = Partial<Payment>;
function updatePayment(id: string, updates: PaymentUpdate): Payment {
const existing = findPayment(id);
return { ...existing, ...updates };
}
// Can specify any subset of properties
updatePayment('PAY-1', { status: 'completed' });
updatePayment('PAY-1', { amount: 200, currency: 'EUR' });
Required<T>
Make all properties required.
interface Config {
apiUrl?: string;
timeout?: number;
retries?: number;
}
// All properties required
type RequiredConfig = Required<Config>;
function initializeApp(config: RequiredConfig) {
// Can safely access all properties
console.log(config.apiUrl, config.timeout, config.retries);
}
Readonly<T>
Make all properties readonly.
type ImmutablePayment = Readonly<Payment>;
const payment: ImmutablePayment = {
id: 'PAY-1',
amount: 100,
currency: 'USD',
status: 'pending'
};
payment.status = 'completed'; // Error: Cannot assign to 'status'
Pick<T, K>
Select a subset of properties.
// Only include specific properties
type PaymentSummary = Pick<Payment, 'id' | 'amount' | 'status'>;
const summary: PaymentSummary = {
id: 'PAY-1',
amount: 100,
status: 'completed'
};
Omit<T, K>
Exclude specific properties.
// Exclude properties
type CreatePaymentRequest = Omit<Payment, 'id' | 'createdAt'>;
const request: CreatePaymentRequest = {
amount: 100,
currency: 'USD',
status: 'pending'
};
Record<K, V>
Create an object type with specific key and value types.
// String keys, Payment values
type PaymentMap = Record<string, Payment>;
const payments: PaymentMap = {
'PAY-1': { id: 'PAY-1', amount: 100, /* ... */ },
'PAY-2': { id: 'PAY-2', amount: 200, /* ... */ }
};
// Literal keys
type StatusCounts = Record<PaymentStatus, number>;
const counts: StatusCounts = {
pending: 10,
completed: 50,
failed: 3,
refunded: 2
};
Exclude<T, U> and Extract<T, U>
Filter union types.
type AllStatus = 'pending' | 'completed' | 'failed' | 'refunded';
// Exclude: remove types from union
type ActiveStatus = Exclude<AllStatus, 'completed' | 'refunded'>;
// Result: 'pending' | 'failed'
// Extract: keep only matching types
type FinalStatus = Extract<AllStatus, 'completed' | 'refunded'>;
// Result: 'completed' | 'refunded'
NonNullable<T>
Remove null and undefined from a type.
type MaybePayment = Payment | null | undefined;
type DefinitePayment = NonNullable<MaybePayment>;
// Result: Payment
ReturnType<T> and Parameters<T>
Extract function type information.
function processPayment(id: string, amount: number): PaymentResult {
// ...
}
// Get return type
type Result = ReturnType<typeof processPayment>;
// Result: PaymentResult
// Get parameter types
type Params = Parameters<typeof processPayment>;
// Result: [string, number]
Function Types
Function Signatures
// Function type alias
type PaymentProcessor = (payment: Payment) => Promise<PaymentResult>;
// Interface with call signature
interface PaymentValidator {
(payment: Payment): ValidationResult;
version: string; // Can also have properties
}
// Arrow function with explicit types
const processPayment: PaymentProcessor = async (payment) => {
// TypeScript knows payment is Payment
return { success: true, transactionId: 'TXN-123' };
};
Overloaded Functions
Overloads provide different signatures for the same function.
// Overload signatures (declarations only)
function parsePayment(data: string): Payment;
function parsePayment(data: object): Payment;
function parsePayment(data: Buffer): Payment;
// Implementation signature (must handle all overloads)
function parsePayment(data: string | object | Buffer): Payment {
if (typeof data === 'string') {
return JSON.parse(data);
}
if (Buffer.isBuffer(data)) {
return JSON.parse(data.toString());
}
return data as Payment;
}
// TypeScript selects correct overload based on argument
const p1 = parsePayment('{"id":"PAY-1"}'); // parsePayment(string)
const p2 = parsePayment({ id: 'PAY-1' }); // parsePayment(object)
Generic Functions
// Generic async function
async function fetchResource<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
// TypeScript infers return type from type argument
const payment = await fetchResource<Payment>('/api/payments/1');
const account = await fetchResource<Account>('/api/accounts/1');
Index Signatures
Index signatures define types for dynamic property access.
// String index signature
interface Dictionary {
[key: string]: string;
}
const translations: Dictionary = {
hello: 'hola',
goodbye: 'adiós'
};
// Number index signature (for array-like objects)
interface ArrayLike {
[index: number]: string;
length: number;
}
// Mixed: specific and dynamic properties
interface Payment {
id: string;
amount: number;
[key: string]: unknown; // Additional dynamic properties
}
const payment: Payment = {
id: 'PAY-1',
amount: 100,
customField: 'value' // Allowed by index signature
};
Type Assertions
Type assertions tell TypeScript to treat a value as a specific type. Use sparingly - they bypass type checking.
// 'as' syntax (preferred)
const input = document.getElementById('payment-form') as HTMLFormElement;
// Angle bracket syntax (doesn't work in JSX)
const input2 = <HTMLFormElement>document.getElementById('payment-form');
// Double assertion (escape hatch - avoid if possible)
const value = (someValue as unknown) as Payment;
// GOOD: Assertion after validation
function processResponse(data: unknown): Payment {
if (isPayment(data)) {
return data; // Type guard already narrowed
}
throw new Error('Invalid payment data');
}
// BAD: Assertion without validation
function processResponseUnsafe(data: unknown): Payment {
return data as Payment; // Runtime error if data isn't a Payment
}
const Assertions
// Without const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
// Type: { apiUrl: string; timeout: number }
// With const assertion
const configConst = {
apiUrl: 'https://api.example.com',
timeout: 5000
} as const;
// Type: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000 }
// Useful for literal types
const STATUSES = ['pending', 'completed', 'failed'] as const;
type Status = typeof STATUSES[number]; // 'pending' | 'completed' | 'failed'
Strict Mode Configuration
Enable all strict flags for maximum type safety.
// tsconfig.json
{
"compilerOptions": {
"strict": true, // Enables all strict flags below
// Individual strict flags (enabled by "strict": true)
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
// Additional recommended flags
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Further Reading
Internal Documentation
- TypeScript Advanced Types - Mapped types, conditional types, template literals
- TypeScript General - Project setup and best practices
- TypeScript Testing - Testing type-safe code
- Core Principles - Engineering principles including SOLID
External Resources
Summary
Key Takeaways
- Enable strict mode for maximum type safety (
"strict": truein tsconfig) - Let TypeScript infer types when it can - add explicit types for public APIs and empty arrays
- Use interfaces for objects, types for unions/intersections/primitives
- Leverage type guards to narrow types safely at runtime
- Generics enable reusable type-safe code - constrain with
extendswhen needed - Utility types (
Partial,Pick,Omit, etc.) handle common transformations - Discriminated unions model state machines with exhaustiveness checking
- Avoid type assertions unless you've validated the data
Next Steps: Review TypeScript Advanced Types for mapped types, conditional types, and type-level programming patterns.