Prerequisites
- Understanding of interfaces π
- Function types knowledge π
- Class and constructor basics π»
What you'll learn
- Create callable interfaces for function types π―
- Design constructable interfaces for classes ποΈ
- Combine callable and regular properties π‘οΈ
- Build type-safe factory patterns β¨
π― Introduction
Welcome to the powerful world of callable and constructable interfaces! π In this guide, weβll explore how TypeScript allows you to define interfaces that describe functions and constructors, unlocking advanced type patterns.
Youβll discover how callable interfaces are like blueprints for functions π - they define not just what a function looks like, but how it behaves. Whether youβre creating factory functions π, designing plugin systems π, or building framework APIs π, understanding callable and constructable interfaces is essential for advanced TypeScript development.
By the end of this tutorial, youβll be confidently creating interfaces that can be called, constructed, and still have properties! Letβs build some powerful types! πββοΈ
π Understanding Callable and Constructable Interfaces
π€ What are Callable Interfaces?
Callable interfaces define the signature of a function while also allowing you to add properties to that function. Itβs like having a function thatβs also an object - which is exactly what JavaScript functions are!
Think of callable interfaces like:
- π Factory functions with configuration
- π Plugins with both functionality and metadata
- π― Event emitters that can be called directly
- π§° Utility functions with attached helpers
π‘ What are Constructable Interfaces?
Constructable interfaces define constructor signatures - they describe how to create instances of a class. Theyβre perfect for defining factory patterns and ensuring constructor compatibility.
Real-world example: jQuery π° - itβs both a function $()
and an object with properties like $.ajax
. This dual nature is perfectly captured by callable interfaces!
π§ Callable Interfaces
π Basic Callable Interface Syntax
Letβs start with the fundamentals:
// π― Simple callable interface
interface SimpleGreeter {
(name: string): string;
}
const greet: SimpleGreeter = (name) => {
return `Hello, ${name}!`;
};
console.log(greet('Alice')); // "Hello, Alice!"
// π§ Callable interface with properties
interface AdvancedGreeter {
// Call signature
(name: string): string;
// Properties
language: string;
formal: boolean;
// Methods
setLanguage(lang: string): void;
reset(): void;
}
const createGreeter = (): AdvancedGreeter => {
let language = 'en';
let formal = false;
const greeter = ((name: string) => {
if (language === 'es') {
return formal ? `Buenos dΓas, SeΓ±or/SeΓ±ora ${name}` : `Β‘Hola, ${name}!`;
}
return formal ? `Good day, ${name}` : `Hello, ${name}!`;
}) as AdvancedGreeter;
greeter.language = language;
greeter.formal = formal;
greeter.setLanguage = (lang: string) => {
language = lang;
greeter.language = lang;
};
greeter.reset = () => {
language = 'en';
formal = false;
greeter.language = language;
greeter.formal = formal;
};
return greeter;
};
const myGreeter = createGreeter();
console.log(myGreeter('John')); // "Hello, John!"
myGreeter.formal = true;
console.log(myGreeter('John')); // "Good day, John"
myGreeter.setLanguage('es');
console.log(myGreeter('Juan')); // "Buenos dΓas, SeΓ±or/SeΓ±ora Juan"
// π Multiple call signatures (overloading)
interface Calculator {
// Overloaded signatures
(a: number, b: number): number;
(a: string, b: string): string;
(a: number[], operation: 'sum' | 'avg' | 'max' | 'min'): number;
// Properties
precision: number;
history: Array<{ operation: string; result: any }>;
// Methods
clearHistory(): void;
}
const createCalculator = (): Calculator => {
const calc = ((a: any, b: any) => {
// Handle different overloads
if (typeof a === 'number' && typeof b === 'number') {
const result = Number((a + b).toFixed(calc.precision));
calc.history.push({ operation: `${a} + ${b}`, result });
return result;
}
if (typeof a === 'string' && typeof b === 'string') {
const result = a + b;
calc.history.push({ operation: `"${a}" + "${b}"`, result });
return result;
}
if (Array.isArray(a)) {
let result: number;
switch (b) {
case 'sum':
result = a.reduce((sum, n) => sum + n, 0);
break;
case 'avg':
result = a.reduce((sum, n) => sum + n, 0) / a.length;
break;
case 'max':
result = Math.max(...a);
break;
case 'min':
result = Math.min(...a);
break;
default:
result = 0;
}
calc.history.push({ operation: `${b}([${a.join(', ')}])`, result });
return Number(result.toFixed(calc.precision));
}
return 0;
}) as Calculator;
calc.precision = 2;
calc.history = [];
calc.clearHistory = () => {
calc.history = [];
};
return calc;
};
const calc = createCalculator();
console.log(calc(10, 20)); // 30
console.log(calc('Hello', ' World')); // "Hello World"
console.log(calc([1, 2, 3, 4, 5], 'avg')); // 3
console.log('History:', calc.history);
ποΈ Advanced Callable Patterns
Building more complex callable interfaces:
// π Plugin system with callable interface
interface Plugin<T = any> {
// Call signature for plugin execution
(context: T): void | Promise<void>;
// Plugin metadata
name: string;
version: string;
description?: string;
dependencies?: string[];
// Lifecycle hooks
install?(options?: any): void | Promise<void>;
uninstall?(): void | Promise<void>;
configure?(config: any): void;
// Plugin capabilities
supports?(feature: string): boolean;
}
class PluginManager<T> {
private plugins: Map<string, Plugin<T>> = new Map();
private installed: Set<string> = new Set();
register(plugin: Plugin<T>): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin '${plugin.name}' already registered`);
}
this.plugins.set(plugin.name, plugin);
console.log(`π¦ Registered plugin: ${plugin.name} v${plugin.version}`);
}
async install(pluginName: string, options?: any): Promise<void> {
const plugin = this.plugins.get(pluginName);
if (!plugin) {
throw new Error(`Plugin '${pluginName}' not found`);
}
if (this.installed.has(pluginName)) {
console.log(`β οΈ Plugin '${pluginName}' already installed`);
return;
}
// Check dependencies
if (plugin.dependencies) {
for (const dep of plugin.dependencies) {
if (!this.installed.has(dep)) {
await this.install(dep);
}
}
}
// Install plugin
if (plugin.install) {
await plugin.install(options);
}
this.installed.add(pluginName);
console.log(`β
Installed plugin: ${pluginName}`);
}
async execute(context: T): Promise<void> {
for (const pluginName of this.installed) {
const plugin = this.plugins.get(pluginName)!;
console.log(`π§ Executing plugin: ${plugin.name}`);
await plugin(context);
}
}
}
// Create plugins
const loggingPlugin: Plugin<{ message: string }> = Object.assign(
(context: { message: string }) => {
console.log(`π [Logger]: ${context.message}`);
},
{
name: 'logger',
version: '1.0.0',
description: 'Simple logging plugin',
install() {
console.log('π§ Logger plugin installed');
},
supports(feature: string) {
return ['console', 'file'].includes(feature);
}
}
);
const validationPlugin: Plugin<{ message: string }> = Object.assign(
(context: { message: string }) => {
if (!context.message || context.message.trim().length === 0) {
throw new Error('Message cannot be empty');
}
console.log('β
Message validated');
},
{
name: 'validator',
version: '1.0.0',
dependencies: ['logger']
}
);
// π¨ Event emitter with callable interface
interface EventEmitter<T = any> {
// Emit event (callable)
(event: string, data?: T): void;
// Event methods
on(event: string, handler: (data: T) => void): void;
off(event: string, handler: (data: T) => void): void;
once(event: string, handler: (data: T) => void): void;
// Properties
maxListeners: number;
eventNames(): string[];
listenerCount(event: string): number;
}
function createEventEmitter<T = any>(): EventEmitter<T> {
const listeners = new Map<string, Set<(data: T) => void>>();
const emitter = ((event: string, data?: T) => {
const handlers = listeners.get(event);
if (handlers) {
handlers.forEach(handler => {
try {
handler(data!);
} catch (error) {
console.error(`Error in event handler for '${event}':`, error);
}
});
}
}) as EventEmitter<T>;
emitter.maxListeners = 10;
emitter.on = (event: string, handler: (data: T) => void) => {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
const handlers = listeners.get(event)!;
if (handlers.size >= emitter.maxListeners) {
console.warn(`β οΈ MaxListeners (${emitter.maxListeners}) exceeded for event '${event}'`);
}
handlers.add(handler);
};
emitter.off = (event: string, handler: (data: T) => void) => {
listeners.get(event)?.delete(handler);
};
emitter.once = (event: string, handler: (data: T) => void) => {
const onceHandler = (data: T) => {
handler(data);
emitter.off(event, onceHandler);
};
emitter.on(event, onceHandler);
};
emitter.eventNames = () => Array.from(listeners.keys());
emitter.listenerCount = (event: string) => {
return listeners.get(event)?.size || 0;
};
return emitter;
}
// π Chainable API with callable interface
interface ChainableQuery<T> {
// Execute query (callable)
(): T[];
// Chainable methods
where(predicate: (item: T) => boolean): ChainableQuery<T>;
orderBy<K extends keyof T>(key: K): ChainableQuery<T>;
limit(count: number): ChainableQuery<T>;
select<U>(selector: (item: T) => U): ChainableQuery<U>;
// Properties
count(): number;
first(): T | undefined;
last(): T | undefined;
}
function createQuery<T>(data: T[]): ChainableQuery<T> {
let items = [...data];
const query = (() => items) as ChainableQuery<T>;
query.where = (predicate: (item: T) => boolean) => {
return createQuery(items.filter(predicate));
};
query.orderBy = <K extends keyof T>(key: K) => {
return createQuery([...items].sort((a, b) => {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
return 0;
}));
};
query.limit = (count: number) => {
return createQuery(items.slice(0, count));
};
query.select = <U>(selector: (item: T) => U) => {
return createQuery(items.map(selector));
};
query.count = () => items.length;
query.first = () => items[0];
query.last = () => items[items.length - 1];
return query;
}
// Usage example
interface User {
id: number;
name: string;
age: number;
active: boolean;
}
const users: User[] = [
{ id: 1, name: 'Alice', age: 30, active: true },
{ id: 2, name: 'Bob', age: 25, active: false },
{ id: 3, name: 'Charlie', age: 35, active: true }
];
const activeUsers = createQuery(users)
.where(u => u.active)
.orderBy('age')
.limit(2)();
console.log('Active users:', activeUsers);
ποΈ Constructable Interfaces
π Basic Constructor Signatures
Creating interfaces for constructors:
// π Simple constructor interface
interface Constructable<T> {
new (...args: any[]): T;
}
interface PointConstructor {
new (x: number, y: number): Point;
}
interface Point {
x: number;
y: number;
distanceTo(other: Point): number;
}
class Point2D implements Point {
constructor(public x: number, public y: number) {}
distanceTo(other: Point): number {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
// Type assertion to match constructor interface
const PointClass: PointConstructor = Point2D;
const point = new PointClass(10, 20);
// π¨ Constructor with static members
interface ColorConstructor {
new (r: number, g: number, b: number): Color;
// Static methods
fromHex(hex: string): Color;
fromHSL(h: number, s: number, l: number): Color;
// Static properties
readonly BLACK: Color;
readonly WHITE: Color;
readonly RED: Color;
}
interface Color {
r: number;
g: number;
b: number;
toHex(): string;
toHSL(): { h: number; s: number; l: number };
}
const ColorClass: ColorConstructor = class implements Color {
constructor(public r: number, public g: number, public b: number) {}
toHex(): string {
const toHex = (n: number) => n.toString(16).padStart(2, '0');
return `#${toHex(this.r)}${toHex(this.g)}${toHex(this.b)}`;
}
toHSL(): { h: number; s: number; l: number } {
// Simplified HSL conversion
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
static fromHex(hex: string): Color {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) throw new Error('Invalid hex color');
return new this(
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
);
}
static fromHSL(h: number, s: number, l: number): Color {
// Simplified HSL to RGB conversion
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const hNorm = h / 360;
const sNorm = s / 100;
const lNorm = l / 100;
let r, g, b;
if (sNorm === 0) {
r = g = b = lNorm;
} else {
const q = lNorm < 0.5
? lNorm * (1 + sNorm)
: lNorm + sNorm - lNorm * sNorm;
const p = 2 * lNorm - q;
r = hue2rgb(p, q, hNorm + 1/3);
g = hue2rgb(p, q, hNorm);
b = hue2rgb(p, q, hNorm - 1/3);
}
return new this(
Math.round(r * 255),
Math.round(g * 255),
Math.round(b * 255)
);
}
static readonly BLACK = new this(0, 0, 0);
static readonly WHITE = new this(255, 255, 255);
static readonly RED = new this(255, 0, 0);
};
// π Factory pattern with constructor interfaces
interface ComponentConstructor<T extends Component> {
new (props: any): T;
displayName: string;
defaultProps?: Partial<T['props']>;
}
interface Component {
props: any;
render(): string;
update(newProps: any): void;
}
class ComponentFactory {
private components = new Map<string, ComponentConstructor<any>>();
register<T extends Component>(
name: string,
constructor: ComponentConstructor<T>
): void {
this.components.set(name, constructor);
console.log(`π¦ Registered component: ${name}`);
}
create<T extends Component>(
name: string,
props: any
): T {
const Constructor = this.components.get(name);
if (!Constructor) {
throw new Error(`Component '${name}' not found`);
}
const mergedProps = {
...Constructor.defaultProps,
...props
};
return new Constructor(mergedProps) as T;
}
listComponents(): string[] {
return Array.from(this.components.keys());
}
}
π Advanced Constructor Patterns
Complex constructor interfaces and patterns:
// π― Generic constructor with constraints
interface Model {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface ModelConstructor<T extends Model> {
new (data: Partial<T>): T;
// Static ORM-like methods
find(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(data: Omit<T, keyof Model>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<boolean>;
// Schema information
readonly tableName: string;
readonly fields: Record<keyof T, FieldDefinition>;
}
interface FieldDefinition {
type: 'string' | 'number' | 'boolean' | 'date';
required: boolean;
unique?: boolean;
default?: any;
validate?: (value: any) => boolean;
}
// ποΈ Builder pattern with constructable interface
interface BuilderConstructor<T, B extends Builder<T>> {
new (): B;
}
interface Builder<T> {
build(): T;
reset(): this;
}
interface QueryBuilder extends Builder<string> {
select(...fields: string[]): this;
from(table: string): this;
where(condition: string): this;
orderBy(field: string, direction?: 'ASC' | 'DESC'): this;
limit(count: number): this;
}
const SQLQueryBuilder: BuilderConstructor<string, QueryBuilder> = class implements QueryBuilder {
private query: {
select: string[];
from: string;
where: string[];
orderBy: { field: string; direction: string }[];
limit?: number;
} = {
select: [],
from: '',
where: [],
orderBy: []
};
select(...fields: string[]): this {
this.query.select.push(...fields);
return this;
}
from(table: string): this {
this.query.from = table;
return this;
}
where(condition: string): this {
this.query.where.push(condition);
return this;
}
orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.query.orderBy.push({ field, direction });
return this;
}
limit(count: number): this {
this.query.limit = count;
return this;
}
build(): string {
const parts: string[] = [];
if (this.query.select.length > 0) {
parts.push(`SELECT ${this.query.select.join(', ')}`);
} else {
parts.push('SELECT *');
}
if (this.query.from) {
parts.push(`FROM ${this.query.from}`);
}
if (this.query.where.length > 0) {
parts.push(`WHERE ${this.query.where.join(' AND ')}`);
}
if (this.query.orderBy.length > 0) {
const orderClauses = this.query.orderBy
.map(o => `${o.field} ${o.direction}`)
.join(', ');
parts.push(`ORDER BY ${orderClauses}`);
}
if (this.query.limit) {
parts.push(`LIMIT ${this.query.limit}`);
}
return parts.join(' ');
}
reset(): this {
this.query = {
select: [],
from: '',
where: [],
orderBy: []
};
return this;
}
};
// π Plugin system with constructable interfaces
interface PluginConstructor<T extends PluginBase> {
new (options?: any): T;
// Plugin metadata
readonly pluginName: string;
readonly version: string;
readonly dependencies?: string[];
// Static validation
validateOptions?(options: any): boolean;
}
interface PluginBase {
initialize(): Promise<void>;
execute(context: any): Promise<void>;
destroy(): Promise<void>;
}
class PluginLoader {
private plugins = new Map<string, PluginBase>();
private constructors = new Map<string, PluginConstructor<any>>();
register<T extends PluginBase>(
PluginClass: PluginConstructor<T>
): void {
const name = PluginClass.pluginName;
if (this.constructors.has(name)) {
throw new Error(`Plugin '${name}' already registered`);
}
this.constructors.set(name, PluginClass);
console.log(`π Registered plugin: ${name} v${PluginClass.version}`);
}
async load(name: string, options?: any): Promise<void> {
const PluginClass = this.constructors.get(name);
if (!PluginClass) {
throw new Error(`Plugin '${name}' not found`);
}
// Validate options if validator exists
if (PluginClass.validateOptions && !PluginClass.validateOptions(options)) {
throw new Error(`Invalid options for plugin '${name}'`);
}
// Check dependencies
if (PluginClass.dependencies) {
for (const dep of PluginClass.dependencies) {
if (!this.plugins.has(dep)) {
await this.load(dep);
}
}
}
// Create and initialize plugin
const plugin = new PluginClass(options);
await plugin.initialize();
this.plugins.set(name, plugin);
console.log(`β
Loaded plugin: ${name}`);
}
async executeAll(context: any): Promise<void> {
for (const [name, plugin] of this.plugins) {
console.log(`π§ Executing plugin: ${name}`);
await plugin.execute(context);
}
}
}
// Example plugin implementation
const LoggerPlugin: PluginConstructor<PluginBase> = class implements PluginBase {
static readonly pluginName = 'logger';
static readonly version = '1.0.0';
private logLevel: string;
constructor(options?: { level?: string }) {
this.logLevel = options?.level || 'info';
}
async initialize(): Promise<void> {
console.log(`π Logger initialized with level: ${this.logLevel}`);
}
async execute(context: any): Promise<void> {
console.log(`[${this.logLevel.toUpperCase()}]`, context);
}
async destroy(): Promise<void> {
console.log('π Logger destroyed');
}
static validateOptions(options: any): boolean {
if (!options) return true;
const validLevels = ['debug', 'info', 'warn', 'error'];
return !options.level || validLevels.includes(options.level);
}
};
π¨ Hybrid Types: Combining Both
π Interfaces That Are Both Callable and Constructable
Creating interfaces with both capabilities:
// π― Hybrid interface example
interface HybridFunction {
// Call signatures
(): void;
(message: string): void;
// Constructor signature
new (config?: any): HybridInstance;
// Static properties
version: string;
instances: HybridInstance[];
// Static methods
configure(options: any): void;
getInstance(id: string): HybridInstance | undefined;
}
interface HybridInstance {
id: string;
execute(): void;
}
// Implementation
const createHybrid = (): HybridFunction => {
const instances: HybridInstance[] = [];
let config: any = {};
// Create the hybrid function
const hybrid = function(this: any, message?: string) {
// Check if called with 'new'
if (new.target) {
// Constructor behavior
const instance: HybridInstance = {
id: `instance_${Date.now()}`,
execute() {
console.log(`π Executing instance ${this.id}`);
}
};
instances.push(instance);
return instance;
}
// Regular function behavior
if (message) {
console.log(`π’ Message: ${message}`);
} else {
console.log('π Default action executed');
}
} as any as HybridFunction;
// Add static properties
hybrid.version = '1.0.0';
hybrid.instances = instances;
// Add static methods
hybrid.configure = (options: any) => {
config = { ...config, ...options };
console.log('βοΈ Configuration updated:', config);
};
hybrid.getInstance = (id: string) => {
return instances.find(inst => inst.id === id);
};
return hybrid;
};
// Usage
const myHybrid = createHybrid();
// Use as function
myHybrid(); // Default action
myHybrid('Hello, World!'); // With message
// Use as constructor
const instance1 = new myHybrid();
const instance2 = new myHybrid({ name: 'Test' });
instance1.execute();
console.log('Total instances:', myHybrid.instances.length);
// ποΈ jQuery-like library interface
interface DOMQuery {
// Call signature - selector function
(selector: string): DOMQuery;
(element: HTMLElement): DOMQuery;
(callback: () => void): void; // Document ready
// Constructor signature
new (selector: string): DOMQuery;
// jQuery-like methods (chainable)
addClass(className: string): DOMQuery;
removeClass(className: string): DOMQuery;
on(event: string, handler: EventListener): DOMQuery;
off(event: string, handler: EventListener): DOMQuery;
css(property: string, value: string): DOMQuery;
html(): string;
html(content: string): DOMQuery;
// Properties
length: number;
// Static methods (like $.ajax)
ajax(options: AjaxOptions): Promise<any>;
extend<T, U>(target: T, source: U): T & U;
isArray(obj: any): obj is any[];
// Plugin system
fn: {
[key: string]: (...args: any[]) => DOMQuery;
};
}
interface AjaxOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: any;
headers?: Record<string, string>;
}
// Simplified implementation
const $ = (function(): DOMQuery {
const DOMQueryImpl = function(this: any, selector: any) {
// Handle different call signatures
if (typeof selector === 'function') {
// Document ready
if (document.readyState === 'complete') {
selector();
} else {
document.addEventListener('DOMContentLoaded', selector);
}
return;
}
// Constructor behavior
if (new.target) {
this.elements = typeof selector === 'string'
? document.querySelectorAll(selector)
: [selector];
this.length = this.elements.length;
return this;
}
// Regular function call
return new (DOMQueryImpl as any)(selector);
} as any as DOMQuery;
// Chainable methods
DOMQueryImpl.prototype.addClass = function(className: string) {
this.elements.forEach((el: HTMLElement) => el.classList.add(className));
return this;
};
DOMQueryImpl.prototype.removeClass = function(className: string) {
this.elements.forEach((el: HTMLElement) => el.classList.remove(className));
return this;
};
DOMQueryImpl.prototype.on = function(event: string, handler: EventListener) {
this.elements.forEach((el: HTMLElement) => el.addEventListener(event, handler));
return this;
};
DOMQueryImpl.prototype.html = function(content?: string) {
if (content === undefined) {
return this.elements[0]?.innerHTML || '';
}
this.elements.forEach((el: HTMLElement) => el.innerHTML = content);
return this;
};
// Static methods
DOMQueryImpl.ajax = async (options: AjaxOptions) => {
const response = await fetch(options.url, {
method: options.method || 'GET',
headers: options.headers,
body: options.data ? JSON.stringify(options.data) : undefined
});
return response.json();
};
DOMQueryImpl.extend = <T, U>(target: T, source: U): T & U => {
return Object.assign({}, target, source);
};
DOMQueryImpl.isArray = Array.isArray;
// Plugin system
DOMQueryImpl.fn = DOMQueryImpl.prototype;
return DOMQueryImpl;
})();
// Usage examples
$(() => {
console.log('Document ready!');
});
// Both work - as function and constructor
const elements1 = $('.my-class');
const elements2 = new $('.my-class');
elements1
.addClass('active')
.on('click', () => console.log('Clicked!'))
.css('color', 'blue');
β οΈ Common Pitfalls and Solutions
π± Pitfall 1: Type Inference Issues
// β Problem - TypeScript can't infer callable interface
const badCallable = (name: string) => {
return `Hello, ${name}`;
};
// Trying to add properties doesn't work
badCallable.language = 'en'; // Error! Property doesn't exist
// β
Solution 1 - Explicit typing
interface Greeter {
(name: string): string;
language: string;
}
const goodCallable: Greeter = Object.assign(
(name: string) => `Hello, ${name}`,
{ language: 'en' }
);
// β
Solution 2 - Factory function
function createCallable(): Greeter {
const fn = (name: string) => `Hello, ${name}`;
(fn as any).language = 'en';
return fn as Greeter;
}
// β
Solution 3 - Class-based approach
class CallableClass {
language = 'en';
constructor() {
const callable = (name: string) => `Hello, ${name}`;
Object.setPrototypeOf(callable, CallableClass.prototype);
Object.assign(callable, this);
return callable as any;
}
}
π€― Pitfall 2: Constructor Type Compatibility
// β Problem - Constructor parameter mismatch
interface AnimalConstructor {
new (name: string): Animal;
}
interface Animal {
name: string;
}
class Dog implements Animal {
constructor(public name: string, public breed: string) {} // Extra parameter!
}
// const DogConstructor: AnimalConstructor = Dog; // Error!
// β
Solution 1 - Make constructor more flexible
interface FlexibleConstructor<T> {
new (...args: any[]): T;
}
const DogConstructor1: FlexibleConstructor<Animal> = Dog; // OK
// β
Solution 2 - Use factory pattern
interface AnimalFactory<T extends Animal> {
create(name: string): T;
}
class DogFactory implements AnimalFactory<Dog> {
create(name: string): Dog {
return new Dog(name, 'Unknown');
}
}
// β
Solution 3 - Adapter pattern
const createAnimalConstructor = <T extends Animal>(
Constructor: new (...args: any[]) => T,
...defaultArgs: any[]
): AnimalConstructor => {
return class {
constructor(name: string) {
return new Constructor(name, ...defaultArgs);
}
} as any;
};
const AdaptedDog = createAnimalConstructor(Dog, 'Mixed');
π οΈ Best Practices
π― Design Guidelines
- Clear Intent π―: Make it obvious when something is callable/constructable
- Type Safety π‘οΈ: Always provide explicit types for complex interfaces
- Documentation π: Document the expected behavior clearly
- Consistency π: Use consistent patterns across your codebase
// π Well-designed callable interface
interface WellDesignedCallable {
// Clear call signatures with JSDoc
/**
* Execute the main function
* @param input - The input to process
* @returns Processed result
*/
(input: string): string;
/**
* Execute with options
* @param input - The input to process
* @param options - Processing options
*/
(input: string, options: ProcessOptions): ProcessedResult;
// Well-organized properties
readonly version: string;
readonly name: string;
// Configuration
config: {
timeout: number;
retries: number;
debug: boolean;
};
// Clear method names
configure(options: Partial<ProcessOptions>): void;
reset(): void;
// Event handling
on(event: 'start' | 'complete' | 'error', handler: Function): void;
off(event: string, handler: Function): void;
}
interface ProcessOptions {
mode: 'fast' | 'accurate';
encoding?: string;
validate?: boolean;
}
interface ProcessedResult {
output: string;
metadata: {
processTime: number;
inputLength: number;
outputLength: number;
};
}
// ποΈ Well-designed constructor interface
interface WellDesignedConstructor<T, O = any> {
// Clear constructor signature
new (options?: O): T;
// Factory methods with descriptive names
create(options?: O): T;
createDefault(): T;
createFrom<U>(source: U, transformer: (source: U) => O): T;
// Validation
validate(instance: any): instance is T;
isValidOptions(options: any): options is O;
// Metadata
readonly className: string;
readonly version: string;
readonly schema?: Schema<T>;
}
interface Schema<T> {
fields: {
[K in keyof T]: FieldSchema;
};
validate(data: any): ValidationResult;
}
interface FieldSchema {
type: string;
required: boolean;
validator?: (value: any) => boolean;
}
interface ValidationResult {
valid: boolean;
errors?: string[];
}
π§ͺ Hands-On Exercise
π― Challenge: Build a Command System
Create a flexible command system using callable and constructable interfaces:
π Requirements:
- β Commands are both callable and have properties
- π¨ Support command chaining
- π― Implement undo/redo functionality
- π Track command history
- π§ Plugin-based command extensions
π Bonus Points:
- Add command validation
- Implement command macros
- Create async command support
π‘ Solution
π Click to see solution
// π― Command system with callable and constructable interfaces
// Command interfaces
interface Command<T = any, R = any> {
// Callable - execute the command
(context: T): R | Promise<R>;
// Properties
readonly name: string;
readonly description: string;
readonly category: string;
// Configuration
config: CommandConfig;
// Methods
canExecute(context: T): boolean;
validate?(args: any[]): boolean;
undo?(context: T): void;
// Metadata
metadata: {
executionCount: number;
lastExecuted?: Date;
averageTime?: number;
};
}
interface CommandConfig {
async: boolean;
undoable: boolean;
requiresConfirmation: boolean;
timeout?: number;
retries?: number;
}
interface CommandConstructor<T = any, R = any> {
new (options?: Partial<CommandConfig>): Command<T, R>;
commandName: string;
category: string;
description: string;
defaultConfig: CommandConfig;
}
// Command history entry
interface HistoryEntry<T = any, R = any> {
command: Command<T, R>;
context: T;
result?: R;
timestamp: Date;
duration: number;
status: 'success' | 'failed' | 'cancelled';
error?: Error;
}
// Command manager
class CommandManager<T = any> {
private commands = new Map<string, Command<T>>();
private history: HistoryEntry<T>[] = [];
private historyIndex = -1;
private constructors = new Map<string, CommandConstructor<T>>();
// Register command instance
register(command: Command<T>): void {
this.commands.set(command.name, command);
console.log(`π Registered command: ${command.name}`);
}
// Register command constructor
registerConstructor(Constructor: CommandConstructor<T>): void {
this.constructors.set(Constructor.commandName, Constructor);
console.log(`ποΈ Registered command constructor: ${Constructor.commandName}`);
}
// Create command from constructor
createCommand(name: string, options?: Partial<CommandConfig>): Command<T> {
const Constructor = this.constructors.get(name);
if (!Constructor) {
throw new Error(`Command constructor '${name}' not found`);
}
return new Constructor(options);
}
// Execute command
async execute(commandName: string, context: T): Promise<any> {
const command = this.commands.get(commandName);
if (!command) {
throw new Error(`Command '${commandName}' not found`);
}
// Check if can execute
if (!command.canExecute(context)) {
throw new Error(`Command '${commandName}' cannot be executed in current context`);
}
// Confirm if required
if (command.config.requiresConfirmation) {
console.log(`β οΈ Command '${commandName}' requires confirmation`);
// In real app, would show confirmation dialog
}
// Execute with timing
const startTime = Date.now();
let result: any;
let status: HistoryEntry['status'] = 'success';
let error: Error | undefined;
try {
// Handle timeout
if (command.config.timeout) {
result = await Promise.race([
command(context),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Command timeout')), command.config.timeout)
)
]);
} else {
result = await command(context);
}
// Update metadata
command.metadata.executionCount++;
command.metadata.lastExecuted = new Date();
} catch (err) {
status = 'failed';
error = err as Error;
console.error(`β Command '${commandName}' failed:`, err);
// Retry if configured
if (command.config.retries && command.config.retries > 0) {
console.log(`π Retrying command (${command.config.retries} attempts left)`);
// Implement retry logic
}
throw err;
}
const duration = Date.now() - startTime;
// Update average time
const prevAvg = command.metadata.averageTime || 0;
const count = command.metadata.executionCount;
command.metadata.averageTime = (prevAvg * (count - 1) + duration) / count;
// Add to history
const entry: HistoryEntry<T> = {
command,
context: this.cloneContext(context),
result,
timestamp: new Date(),
duration,
status,
error
};
// Truncate forward history if we're not at the end
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
this.history.push(entry);
this.historyIndex = this.history.length - 1;
console.log(`β
Executed '${commandName}' in ${duration}ms`);
return result;
}
// Undo last command
async undo(): Promise<boolean> {
if (this.historyIndex < 0) {
console.log('β Nothing to undo');
return false;
}
const entry = this.history[this.historyIndex];
if (!entry.command.config.undoable || !entry.command.undo) {
console.log(`β Command '${entry.command.name}' is not undoable`);
return false;
}
try {
await entry.command.undo(entry.context);
this.historyIndex--;
console.log(`β©οΈ Undid '${entry.command.name}'`);
return true;
} catch (error) {
console.error('β Undo failed:', error);
return false;
}
}
// Redo command
async redo(): Promise<boolean> {
if (this.historyIndex >= this.history.length - 1) {
console.log('β Nothing to redo');
return false;
}
this.historyIndex++;
const entry = this.history[this.historyIndex];
try {
await entry.command(entry.context);
console.log(`βͺοΈ Redid '${entry.command.name}'`);
return true;
} catch (error) {
console.error('β Redo failed:', error);
this.historyIndex--;
return false;
}
}
// Get command history
getHistory(): HistoryEntry<T>[] {
return [...this.history];
}
// Create command chain
chain(...commandNames: string[]): ChainedCommand<T> {
return new ChainedCommand(this, commandNames);
}
// Create macro
macro(name: string, ...commandNames: string[]): void {
const macroCommand = this.createMacroCommand(name, commandNames);
this.register(macroCommand);
}
private createMacroCommand(name: string, commandNames: string[]): Command<T> {
const manager = this;
const macro = (async function(context: T) {
const results = [];
for (const cmdName of commandNames) {
const result = await manager.execute(cmdName, context);
results.push(result);
}
return results;
}) as Command<T>;
macro.name = name;
macro.description = `Macro: ${commandNames.join(' -> ')}`;
macro.category = 'macro';
macro.config = {
async: true,
undoable: false,
requiresConfirmation: false
};
macro.canExecute = (context: T) => {
return commandNames.every(cmdName => {
const cmd = manager.commands.get(cmdName);
return cmd && cmd.canExecute(context);
});
};
macro.metadata = {
executionCount: 0
};
return macro;
}
private cloneContext(context: T): T {
return JSON.parse(JSON.stringify(context));
}
}
// Chained command builder
class ChainedCommand<T> {
constructor(
private manager: CommandManager<T>,
private commandNames: string[]
) {}
then(commandName: string): ChainedCommand<T> {
this.commandNames.push(commandName);
return this;
}
async execute(context: T): Promise<any[]> {
const results = [];
for (const commandName of this.commandNames) {
const result = await this.manager.execute(commandName, context);
results.push(result);
}
return results;
}
}
// Example command implementations
interface AppContext {
data: any[];
selectedItems: any[];
clipboard: any[];
user: { name: string; role: string };
}
// Create base command class
abstract class BaseCommand implements Command<AppContext> {
readonly name: string;
readonly description: string;
readonly category: string;
config: CommandConfig;
metadata = { executionCount: 0 };
constructor(name: string, description: string, category: string, config?: Partial<CommandConfig>) {
this.name = name;
this.description = description;
this.category = category;
this.config = {
async: false,
undoable: false,
requiresConfirmation: false,
...config
};
}
abstract execute(context: AppContext): any;
canExecute(context: AppContext): boolean {
return true;
}
// Make callable
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Command(${this.name})`;
}
}
// Copy command
const CopyCommand: CommandConstructor<AppContext> = class extends BaseCommand {
static commandName = 'copy';
static category = 'edit';
static description = 'Copy selected items to clipboard';
static defaultConfig: CommandConfig = {
async: false,
undoable: false,
requiresConfirmation: false
};
constructor(options?: Partial<CommandConfig>) {
super(
CopyCommand.commandName,
CopyCommand.description,
CopyCommand.category,
{ ...CopyCommand.defaultConfig, ...options }
);
// Make instance callable
const callable = this.execute.bind(this);
Object.setPrototypeOf(callable, CopyCommand.prototype);
Object.assign(callable, this);
return callable as any;
}
execute(context: AppContext): void {
context.clipboard = [...context.selectedItems];
console.log(`π Copied ${context.clipboard.length} items`);
}
canExecute(context: AppContext): boolean {
return context.selectedItems.length > 0;
}
};
// Paste command with undo
const PasteCommand: CommandConstructor<AppContext> = class extends BaseCommand {
static commandName = 'paste';
static category = 'edit';
static description = 'Paste items from clipboard';
static defaultConfig: CommandConfig = {
async: false,
undoable: true,
requiresConfirmation: false
};
private pastedItems: any[] = [];
constructor(options?: Partial<CommandConfig>) {
super(
PasteCommand.commandName,
PasteCommand.description,
PasteCommand.category,
{ ...PasteCommand.defaultConfig, ...options }
);
const callable = this.execute.bind(this);
Object.setPrototypeOf(callable, PasteCommand.prototype);
Object.assign(callable, this);
return callable as any;
}
execute(context: AppContext): void {
this.pastedItems = [...context.clipboard];
context.data.push(...this.pastedItems);
console.log(`π Pasted ${this.pastedItems.length} items`);
}
canExecute(context: AppContext): boolean {
return context.clipboard.length > 0;
}
undo(context: AppContext): void {
// Remove pasted items
this.pastedItems.forEach(item => {
const index = context.data.indexOf(item);
if (index > -1) {
context.data.splice(index, 1);
}
});
console.log(`β©οΈ Undid paste of ${this.pastedItems.length} items`);
this.pastedItems = [];
}
};
// Delete command with confirmation
const DeleteCommand: CommandConstructor<AppContext> = class extends BaseCommand {
static commandName = 'delete';
static category = 'edit';
static description = 'Delete selected items';
static defaultConfig: CommandConfig = {
async: false,
undoable: true,
requiresConfirmation: true
};
private deletedItems: Array<{ item: any; index: number }> = [];
constructor(options?: Partial<CommandConfig>) {
super(
DeleteCommand.commandName,
DeleteCommand.description,
DeleteCommand.category,
{ ...DeleteCommand.defaultConfig, ...options }
);
const callable = this.execute.bind(this);
Object.setPrototypeOf(callable, DeleteCommand.prototype);
Object.assign(callable, this);
return callable as any;
}
execute(context: AppContext): void {
this.deletedItems = [];
context.selectedItems.forEach(item => {
const index = context.data.indexOf(item);
if (index > -1) {
this.deletedItems.push({ item, index });
context.data.splice(index, 1);
}
});
context.selectedItems = [];
console.log(`ποΈ Deleted ${this.deletedItems.length} items`);
}
canExecute(context: AppContext): boolean {
return context.selectedItems.length > 0 &&
context.user.role !== 'viewer';
}
undo(context: AppContext): void {
// Restore deleted items at their original positions
this.deletedItems
.sort((a, b) => a.index - b.index)
.forEach(({ item, index }) => {
context.data.splice(index, 0, item);
});
console.log(`β©οΈ Restored ${this.deletedItems.length} items`);
this.deletedItems = [];
}
};
// Usage example
const manager = new CommandManager<AppContext>();
// Register constructors
manager.registerConstructor(CopyCommand);
manager.registerConstructor(PasteCommand);
manager.registerConstructor(DeleteCommand);
// Create and register command instances
const copyCmd = manager.createCommand('copy');
const pasteCmd = manager.createCommand('paste');
const deleteCmd = manager.createCommand('delete', { requiresConfirmation: false });
manager.register(copyCmd);
manager.register(pasteCmd);
manager.register(deleteCmd);
// Create macro
manager.macro('cut', 'copy', 'delete');
// Test context
const context: AppContext = {
data: ['Item 1', 'Item 2', 'Item 3'],
selectedItems: ['Item 2'],
clipboard: [],
user: { name: 'John', role: 'admin' }
};
// Execute commands
async function demo() {
console.log('Initial data:', context.data);
// Copy
await manager.execute('copy', context);
// Delete
await manager.execute('delete', context);
console.log('After delete:', context.data);
// Paste
context.selectedItems = [];
await manager.execute('paste', context);
console.log('After paste:', context.data);
// Undo paste
await manager.undo();
console.log('After undo paste:', context.data);
// Redo paste
await manager.redo();
console.log('After redo paste:', context.data);
// Chain commands
context.selectedItems = ['Item 1'];
await manager.chain('copy', 'delete', 'paste').execute(context);
console.log('After chain:', context.data);
// Show history
console.log('\nπ Command History:');
manager.getHistory().forEach(entry => {
console.log(`- ${entry.command.name} (${entry.status}) - ${entry.duration}ms`);
});
}
demo();
π Key Takeaways
You now understand how to create powerful callable and constructable interfaces! Hereβs what youβve learned:
- β Callable interfaces define function signatures with properties π―
- β Constructable interfaces describe constructor signatures ποΈ
- β Hybrid types combine both capabilities π
- β Factory patterns work great with these interfaces π
- β Real-world applications like jQuery-style APIs β¨
Remember: Functions in JavaScript are objects too - TypeScriptβs callable interfaces let you fully express this power! π
π€ Next Steps
Congratulations! π Youβve mastered callable and constructable interfaces!
Hereβs what to do next:
- π» Practice with the command system exercise above
- ποΈ Create your own callable APIs
- π Move on to our next tutorial: Hybrid Types: Combining Multiple Type Kinds
- π Apply these patterns to build powerful, flexible APIs!
Remember: The best APIs are both powerful and intuitive. Keep building! π
Happy coding! ππβ¨