Skip to content

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

  1. File metadata model: name, size (bytes), extension, last modified date, file type (FILE/DIRECTORY).
  2. Individual filters: by name (contains/exact), by size (gt/lt/eq), by extension, by date range, by type.
  3. Composite filters: AND, OR, NOT — arbitrarily nestable.
  4. Sorting: by any field, ascending or descending.
  5. 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

EntityResponsibility
FileTypeEnum: FILE, DIRECTORY
FileMetadataImmutable data object representing a file's attributes
FileFilter (interface)Abstract base — any filter implements matches(file) -> boolean
NameFilterMatches files by name (contains or exact)
SizeFilterMatches files by size with a comparison operator
ExtensionFilterMatches files by extension
DateFilterMatches files within a date range
TypeFilterMatches files by FILE or DIRECTORY
AndFilterComposite: all child filters must match
OrFilterComposite: at least one child filter must match
NotFilterComposite: inverts a child filter
SortCriteriaDefines field and direction for sorting
FileFilterEngineApplies 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: string
  • size: number (bytes)
  • extension: string
  • modifiedAt: Date
  • fileType: 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

typescript
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 implements FileFilter. 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 a FileFilter is expected. The engine does not distinguish between SizeFilter and AndFilter(SizeFilter, NameFilter).

Implementation

Individual Filters

typescript
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

typescript
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

typescript
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.
typescript
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

  1. Empty file list — Engine returns empty list. No errors.
  2. No filter provided — All files pass through, only sorting is applied.
  3. Contradictory filtersAnd(SizeFilter("gt", 100), SizeFilter("lt", 50)) matches nothing. This is valid — it is just an empty result.
  4. Deeply nested compositesAnd(Or(And(...), ...), Not(Or(...))). Works because of recursive matches() calls. Practically limited by the JavaScript call stack depth.
  5. Case sensitivity — Name and extension filters normalize to lowercase. This is a design choice — you could make it configurable.
  6. Extension with or without dotExtensionFilter strips the leading dot, so .py and py both work.

Verification

Setup: Five files:

  • report.pdf — 5MB, modified 2024-01-15, FILE
  • data.csv — 200KB, modified 2024-06-01, FILE
  • backup — directory, 0 bytes, modified 2024-03-10, DIRECTORY
  • notes.txt — 1KB, modified 2024-08-20, FILE
  • archive.pdf — 50MB, modified 2023-12-01, FILE

Query: "PDF files larger than 1MB, sorted by size descending."

typescript
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" — MATCH
  • data.csv — extension is "csv" — NO
  • backup — extension is "" — NO
  • notes.txt — extension is "txt" — NO
  • archive.pdf — extension is "pdf" — MATCH

Step 2: Apply SizeFilter("gt", 1_000_000) (on remaining)

  • report.pdf — 5MB > 1MB — MATCH
  • archive.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, then report.pdf (5MB).

Final result: [archive.pdf, report.pdf].


Complete Code Implementation

typescript
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.

typescript
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

typescript
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:

typescript
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

LevelExpectations
JuniorDefine FileMetadata and a few filter classes. Filter a list with basic criteria. May hardcode filter logic in the engine.
Mid-levelAbstract FileFilter interface, concrete implementations, AND/OR/NOT composites. Discuss the Strategy and Composite patterns by name. Implement sorting.
SeniorEverything 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).

Frontend interview preparation reference.