Skip to main content

TypeScript Type System Fundamentals

Why TypeScript Types Matter

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

  1. Strict Mode Always: Enable all strict flags for maximum safety
  2. Let TypeScript Infer: Avoid explicit types when inference is correct
  3. Prefer Interfaces for Objects: Use type for unions, intersections, and aliases
  4. Use Type Guards for Narrowing: Let TypeScript know specific types through runtime checks
  5. 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 CasePreferReason
Object shapesinterfaceExtensible, better error messages
Union typestypeInterfaces can't express unions
Intersection typestypeCleaner syntax than extends
Primitive aliasestypeInterfaces can't alias primitives
Function typestypeMore readable syntax
Mapped/conditional typestypeInterfaces 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

External Resources


Summary

Key Takeaways

  1. Enable strict mode for maximum type safety ("strict": true in tsconfig)
  2. Let TypeScript infer types when it can - add explicit types for public APIs and empty arrays
  3. Use interfaces for objects, types for unions/intersections/primitives
  4. Leverage type guards to narrow types safely at runtime
  5. Generics enable reusable type-safe code - constrain with extends when needed
  6. Utility types (Partial, Pick, Omit, etc.) handle common transformations
  7. Discriminated unions model state machines with exhaustiveness checking
  8. 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.