12 — LLD: File Filtering API System
Understanding the Problem
You are designing an API that lets users search and filter files based on various criteria — name, size, extension, date modified, file type. Filters should be composable: you can AND them, OR them, and negate them. Think of it like building a query builder for a file system.
What is a File Filtering API?
Imagine the "Advanced Search" dialog in any file manager. Users combine criteria ("files larger than 10MB AND ending in .log AND modified after January 1st") to narrow down results. The LLD challenge is designing a composable, extensible filter pipeline using the right patterns.
Requirements
Clarifying Questions
You: So the input is a list of files with metadata, and the output is a filtered subset based on user-defined criteria?
Interviewer: Exactly. The files are already loaded — you are not crawling a file system. Focus on the filtering and composition logic.
You: What metadata does each file have?
Interviewer: Name, size in bytes, extension, last modified date, and a type like FILE or DIRECTORY.
You: Should filters be composable? Like "size > 10MB AND extension = .log"?
Interviewer: Yes. AND, OR, and NOT composition. Users should be able to nest them arbitrarily.
You: Should I also support sorting the filtered results?
Interviewer: Yes, by any of the metadata fields, ascending or descending.
You: Is this an in-memory system, or should I think about database queries?
Interviewer: In-memory. The file list fits in memory. Focus on clean design, not distributed concerns.
You: Should adding a new filter criterion (say, "owner") be easy without changing existing code?
Interviewer: Absolutely. That is a key part of what I am looking for.
Final Requirements
- File metadata model: name, size (bytes), extension, last modified date, file type (FILE/DIRECTORY).
- Individual filters: by name (contains/exact), by size (gt/lt/eq), by extension, by date range, by type.
- Composite filters: AND, OR, NOT — arbitrarily nestable.
- Sorting: by any field, ascending or descending.
- Extensible: adding a new filter requires implementing one class, no changes elsewhere.
Out of scope: File system crawling, permissions, pagination, caching.
Core Entities and Relationships
| Entity | Responsibility |
|---|---|
FileType | Enum: FILE, DIRECTORY |
FileMetadata | Immutable data object representing a file's attributes |
FileFilter (interface) | Abstract base — any filter implements matches(file) -> boolean |
NameFilter | Matches files by name (contains or exact) |
SizeFilter | Matches files by size with a comparison operator |
ExtensionFilter | Matches files by extension |
DateFilter | Matches files within a date range |
TypeFilter | Matches files by FILE or DIRECTORY |
AndFilter | Composite: all child filters must match |
OrFilter | Composite: at least one child filter must match |
NotFilter | Composite: inverts a child filter |
SortCriteria | Defines field and direction for sorting |
FileFilterEngine | Applies filters and sorting to a file list |
Why the Composite pattern? AndFilter, OrFilter, and NotFilter are themselves FileFilter implementations. This means you can nest And(SizeFilter, Or(NameFilter, ExtensionFilter)) without the engine knowing about the nesting depth. The tree structure handles arbitrary complexity.
Why Strategy for individual filters? Each filter encapsulates one matching strategy. The engine does not know or care which filter types exist. It just calls matches().
Class Design
FileMetadata
State:
name: stringsize: number(bytes)extension: stringmodifiedAt: DatefileType: FileType
FileFilter (Abstract Base)
Methods:
matches(file: FileMetadata) -> boolean
Every concrete filter implements this single method. This is the Strategy pattern — the algorithm (matching logic) varies by filter type, but the interface is uniform.
Composite Filters
AndFilter(filters) — returns true only if all child filters return true. OrFilter(filters) — returns true if any child filter returns true. NotFilter(filter) — returns the inverse of the child filter.
These are both Composite and Decorator patterns at work. NotFilter wraps a single filter (decorator). AndFilter and OrFilter wrap multiple filters (composite).
Final Class Design
enum FileType {
FILE = "file",
DIRECTORY = "directory",
}
interface FileMetadata {
readonly name: string;
readonly size: number; // bytes
readonly extension: string;
readonly modifiedAt: Date;
readonly fileType: FileType;
}
interface FileFilter {
matches(file: FileMetadata): boolean;
}Design principles at play:
- Open/Closed Principle: Adding a new filter (say,
OwnerFilter) means writing one new class that implementsFileFilter. Nothing else changes. - Interface Segregation: Every filter has exactly one method:
matches. No bloated interfaces. - Liskov Substitution: Any
FileFilter— simple or composite — can be used wherever aFileFilteris expected. The engine does not distinguish betweenSizeFilterandAndFilter(SizeFilter, NameFilter).
Implementation
Individual Filters
class NameFilter implements FileFilter {
private readonly pattern: string;
private readonly exact: boolean;
constructor(pattern: string, exact: boolean = false) {
this.pattern = pattern.toLowerCase();
this.exact = exact;
}
matches(file: FileMetadata): boolean {
const name = file.name.toLowerCase();
return this.exact ? name === this.pattern : name.includes(this.pattern);
}
}
class SizeFilter implements FileFilter {
private readonly operator: string;
private readonly size: number;
private readonly ops: Record<string, (a: number, b: number) => boolean> = {
gt: (a, b) => a > b,
lt: (a, b) => a < b,
eq: (a, b) => a === b,
gte: (a, b) => a >= b,
lte: (a, b) => a <= b,
};
constructor(operator: string, size: number) {
if (!(operator in this.ops)) {
throw new Error(`Unknown operator: ${operator}`);
}
this.operator = operator;
this.size = size;
}
matches(file: FileMetadata): boolean {
return this.ops[this.operator](file.size, this.size);
}
}
class ExtensionFilter implements FileFilter {
private readonly extension: string;
constructor(extension: string) {
this.extension = extension.toLowerCase().replace(/^\./, "");
}
matches(file: FileMetadata): boolean {
return file.extension.toLowerCase().replace(/^\./, "") === this.extension;
}
}
class DateFilter implements FileFilter {
private readonly after: Date | null;
private readonly before: Date | null;
constructor(after: Date | null = null, before: Date | null = null) {
this.after = after;
this.before = before;
}
matches(file: FileMetadata): boolean {
if (this.after && file.modifiedAt < this.after) return false;
if (this.before && file.modifiedAt > this.before) return false;
return true;
}
}
class TypeFilter implements FileFilter {
private readonly fileType: FileType;
constructor(fileType: FileType) {
this.fileType = fileType;
}
matches(file: FileMetadata): boolean {
return file.fileType === this.fileType;
}
}Composite Filters
class AndFilter implements FileFilter {
private readonly filters: FileFilter[];
constructor(...filters: FileFilter[]) {
this.filters = filters;
}
matches(file: FileMetadata): boolean {
return this.filters.every((f) => f.matches(file));
}
}
class OrFilter implements FileFilter {
private readonly filters: FileFilter[];
constructor(...filters: FileFilter[]) {
this.filters = filters;
}
matches(file: FileMetadata): boolean {
return this.filters.some((f) => f.matches(file));
}
}
class NotFilter implements FileFilter {
private readonly inner: FileFilter;
constructor(inner: FileFilter) {
this.inner = inner;
}
matches(file: FileMetadata): boolean {
return !this.inner.matches(file);
}
}Sorting
enum SortField {
NAME = "name",
SIZE = "size",
EXTENSION = "extension",
MODIFIED_AT = "modifiedAt",
}
enum SortDirection {
ASC = "asc",
DESC = "desc",
}
class SortCriteria {
constructor(
public readonly field: SortField,
public readonly direction: SortDirection = SortDirection.ASC,
) {}
getKey(file: FileMetadata): string | number | Date {
return file[this.field] as string | number | Date;
}
}Filter Engine
Bad approach: Hardcode filter types in the engine with if/else chains.
- Every new filter type requires modifying the engine. Violates Open/Closed.
Good approach: Engine accepts any FileFilter and just calls matches().
- Clean, but no composition.
Great approach: Engine accepts any FileFilter (including composites) and applies sorting.
- Fully extensible. The engine is under 20 lines and never needs to change.
class FileFilterEngine {
apply(
files: FileMetadata[],
filter?: FileFilter,
sortCriteria?: SortCriteria[],
): FileMetadata[] {
// Step 1: Filter
let result = filter
? files.filter((f) => filter.matches(f))
: [...files];
// Step 2: Sort (apply in reverse order for multi-field sorting)
if (sortCriteria) {
for (const criteria of [...sortCriteria].reverse()) {
result.sort((a, b) => {
const aKey = criteria.getKey(a);
const bKey = criteria.getKey(b);
let cmp = 0;
if (aKey < bKey) cmp = -1;
else if (aKey > bKey) cmp = 1;
return criteria.direction === SortDirection.DESC ? -cmp : cmp;
});
}
}
return result;
}
}Edge Cases
- Empty file list — Engine returns empty list. No errors.
- No filter provided — All files pass through, only sorting is applied.
- Contradictory filters —
And(SizeFilter("gt", 100), SizeFilter("lt", 50))matches nothing. This is valid — it is just an empty result. - Deeply nested composites —
And(Or(And(...), ...), Not(Or(...))). Works because of recursivematches()calls. Practically limited by the JavaScript call stack depth. - Case sensitivity — Name and extension filters normalize to lowercase. This is a design choice — you could make it configurable.
- Extension with or without dot —
ExtensionFilterstrips the leading dot, so.pyandpyboth work.
Verification
Setup: Five files:
report.pdf— 5MB, modified 2024-01-15, FILEdata.csv— 200KB, modified 2024-06-01, FILEbackup— directory, 0 bytes, modified 2024-03-10, DIRECTORYnotes.txt— 1KB, modified 2024-08-20, FILEarchive.pdf— 50MB, modified 2023-12-01, FILE
Query: "PDF files larger than 1MB, sorted by size descending."
const filter = new AndFilter(
new ExtensionFilter("pdf"),
new SizeFilter("gt", 1_000_000), // 1MB
new TypeFilter(FileType.FILE),
);
const sort = [new SortCriteria(SortField.SIZE, SortDirection.DESC)];Step 1: Apply ExtensionFilter("pdf")
report.pdf— extension is "pdf" — MATCHdata.csv— extension is "csv" — NObackup— extension is "" — NOnotes.txt— extension is "txt" — NOarchive.pdf— extension is "pdf" — MATCH
Step 2: Apply SizeFilter("gt", 1_000_000) (on remaining)
report.pdf— 5MB > 1MB — MATCHarchive.pdf— 50MB > 1MB — MATCH
Step 3: Apply TypeFilter(FILE) (on remaining)
- Both are FILE type — MATCH
Step 4: Sort by size DESC
archive.pdf(50MB) comes first, thenreport.pdf(5MB).
Final result: [archive.pdf, report.pdf].
Complete Code Implementation
enum FileType {
FILE = "file",
DIRECTORY = "directory",
}
interface FileMetadata {
readonly name: string;
readonly size: number;
readonly extension: string;
readonly modifiedAt: Date;
readonly fileType: FileType;
}
interface FileFilter {
matches(file: FileMetadata): boolean;
}
// ---- Concrete Filters ----
class NameFilter implements FileFilter {
private readonly pattern: string;
private readonly exact: boolean;
constructor(pattern: string, exact: boolean = false) {
this.pattern = pattern.toLowerCase();
this.exact = exact;
}
matches(file: FileMetadata): boolean {
const name = file.name.toLowerCase();
return this.exact ? name === this.pattern : name.includes(this.pattern);
}
}
class SizeFilter implements FileFilter {
private readonly operator: string;
private readonly size: number;
private readonly ops: Record<string, (a: number, b: number) => boolean> = {
gt: (a, b) => a > b,
lt: (a, b) => a < b,
eq: (a, b) => a === b,
gte: (a, b) => a >= b,
lte: (a, b) => a <= b,
};
constructor(operator: string, size: number) {
if (!(operator in this.ops)) {
throw new Error(`Unknown operator: ${operator}`);
}
this.operator = operator;
this.size = size;
}
matches(file: FileMetadata): boolean {
return this.ops[this.operator](file.size, this.size);
}
}
class ExtensionFilter implements FileFilter {
private readonly extension: string;
constructor(extension: string) {
this.extension = extension.toLowerCase().replace(/^\./, "");
}
matches(file: FileMetadata): boolean {
return file.extension.toLowerCase().replace(/^\./, "") === this.extension;
}
}
class DateFilter implements FileFilter {
private readonly after: Date | null;
private readonly before: Date | null;
constructor(after: Date | null = null, before: Date | null = null) {
this.after = after;
this.before = before;
}
matches(file: FileMetadata): boolean {
if (this.after && file.modifiedAt < this.after) return false;
if (this.before && file.modifiedAt > this.before) return false;
return true;
}
}
class TypeFilter implements FileFilter {
private readonly fileType: FileType;
constructor(fileType: FileType) {
this.fileType = fileType;
}
matches(file: FileMetadata): boolean {
return file.fileType === this.fileType;
}
}
// ---- Composite Filters ----
class AndFilter implements FileFilter {
private readonly filters: FileFilter[];
constructor(...filters: FileFilter[]) {
this.filters = filters;
}
matches(file: FileMetadata): boolean {
return this.filters.every((f) => f.matches(file));
}
}
class OrFilter implements FileFilter {
private readonly filters: FileFilter[];
constructor(...filters: FileFilter[]) {
this.filters = filters;
}
matches(file: FileMetadata): boolean {
return this.filters.some((f) => f.matches(file));
}
}
class NotFilter implements FileFilter {
private readonly inner: FileFilter;
constructor(inner: FileFilter) {
this.inner = inner;
}
matches(file: FileMetadata): boolean {
return !this.inner.matches(file);
}
}
// ---- Sorting ----
enum SortField {
NAME = "name",
SIZE = "size",
EXTENSION = "extension",
MODIFIED_AT = "modifiedAt",
}
enum SortDirection {
ASC = "asc",
DESC = "desc",
}
class SortCriteria {
constructor(
public readonly field: SortField,
public readonly direction: SortDirection = SortDirection.ASC,
) {}
getKey(file: FileMetadata): string | number | Date {
return file[this.field] as string | number | Date;
}
}
// ---- Engine ----
class FileFilterEngine {
apply(
files: FileMetadata[],
filter?: FileFilter,
sortCriteria?: SortCriteria[],
): FileMetadata[] {
let result = filter
? files.filter((f) => filter.matches(f))
: [...files];
if (sortCriteria) {
for (const criteria of [...sortCriteria].reverse()) {
result.sort((a, b) => {
const aKey = criteria.getKey(a);
const bKey = criteria.getKey(b);
let cmp = 0;
if (aKey < bKey) cmp = -1;
else if (aKey > bKey) cmp = 1;
return criteria.direction === SortDirection.DESC ? -cmp : cmp;
});
}
}
return result;
}
}
// ---- Demo ----
const files: FileMetadata[] = [
{ name: "report.pdf", size: 5_000_000, extension: "pdf", modifiedAt: new Date(2024, 0, 15), fileType: FileType.FILE },
{ name: "data.csv", size: 200_000, extension: "csv", modifiedAt: new Date(2024, 5, 1), fileType: FileType.FILE },
{ name: "backup", size: 0, extension: "", modifiedAt: new Date(2024, 2, 10), fileType: FileType.DIRECTORY },
{ name: "notes.txt", size: 1_000, extension: "txt", modifiedAt: new Date(2024, 7, 20), fileType: FileType.FILE },
{ name: "archive.pdf", size: 50_000_000, extension: "pdf", modifiedAt: new Date(2023, 11, 1), fileType: FileType.FILE },
];
const engine = new FileFilterEngine();
// PDF files larger than 1MB, sorted by size descending
const query = new AndFilter(
new ExtensionFilter("pdf"),
new SizeFilter("gt", 1_000_000),
new TypeFilter(FileType.FILE),
);
const sort = [new SortCriteria(SortField.SIZE, SortDirection.DESC)];
const results = engine.apply(files, query, sort);
console.log("Large PDFs (by size desc):");
for (const f of results) {
console.log(` ${f.name} — ${(f.size / 1_000_000).toFixed(1)}MB`);
}
// Files that are NOT directories, modified after 2024-03-01
const query2 = new AndFilter(
new NotFilter(new TypeFilter(FileType.DIRECTORY)),
new DateFilter(new Date(2024, 2, 1)),
);
const results2 = engine.apply(files, query2);
console.log("\nNon-directory files modified after March 2024:");
for (const f of results2) {
console.log(` ${f.name} — ${f.modifiedAt.toISOString().split("T")[0]}`);
}Extensibility
1. Adding a New Filter: OwnerFilter
Create a new class. Nothing else changes.
interface FileMetadataWithOwner extends FileMetadata {
readonly owner: string;
}
class OwnerFilter implements FileFilter {
private readonly owner: string;
constructor(owner: string) {
this.owner = owner.toLowerCase();
}
matches(file: FileMetadata): boolean {
const f = file as FileMetadataWithOwner;
return (f.owner ?? "").toLowerCase() === this.owner;
}
}Tradeoff: Adding a field to FileMetadata is a schema change. If FileMetadata is a readonly interface, all existing objects are unaffected. New files include the owner. This is backward-compatible because the default is "".
2. Regex-Based Name Filtering
class RegexNameFilter implements FileFilter {
private readonly regex: RegExp;
constructor(pattern: string) {
this.regex = new RegExp(pattern, "i");
}
matches(file: FileMetadata): boolean {
return this.regex.test(file.name);
}
}Tradeoff: Regex is powerful but can be slow on large datasets if the pattern is pathological (e.g., catastrophic backtracking). For an in-memory file list of reasonable size, this is fine.
3. Filter Builder (Fluent API)
For a more user-friendly API, wrap the filter construction:
class FilterBuilder {
private filters: FileFilter[] = [];
nameContains(pattern: string): this {
this.filters.push(new NameFilter(pattern));
return this;
}
largerThan(size: number): this {
this.filters.push(new SizeFilter("gt", size));
return this;
}
extension(ext: string): this {
this.filters.push(new ExtensionFilter(ext));
return this;
}
build(): FileFilter {
if (this.filters.length === 1) return this.filters[0];
return new AndFilter(...this.filters);
}
}
// Usage:
const f = new FilterBuilder()
.nameContains("report")
.largerThan(1000)
.extension("pdf")
.build();Tradeoff: Convenient for simple queries but does not support OR/NOT easily. You could add .or() and .not() methods, but the DSL gets complex. For power users, direct composition with AndFilter/OrFilter/NotFilter remains the better option.
What is Expected at Each Level
| Level | Expectations |
|---|---|
| Junior | Define FileMetadata and a few filter classes. Filter a list with basic criteria. May hardcode filter logic in the engine. |
| Mid-level | Abstract FileFilter interface, concrete implementations, AND/OR/NOT composites. Discuss the Strategy and Composite patterns by name. Implement sorting. |
| Senior | Everything above plus: frozen/readonly interfaces for immutability, fluent builder API discussion, analysis of Open/Closed principle in action, discussion of how this translates to database queries if files are stored externally, performance considerations for large datasets (indexing, lazy evaluation with generators). |