Skip to main content

Strategy — Functions Being First-Class

This chapter separates the cases where a function is enough from the cases where bundling into an object earns its keep.

The problem this pattern set out to solve

GoF catalogs Strategy as a pattern that lets you pick one algorithm from a family at runtime1.

Code that picks an algorithm with if or switch means rewriting the same spot every time a kind is added. Strategy pulls the algorithms out into separate objects and turns the choice into "which object do you pass."

This is another pattern where a premise on the language side is at work. In a language where the algorithm itself cannot be passed as a value, you need a container to carry the algorithm around, and that container was a class.

Writing it straightforwardly with classes

The subject here is switching the compression format for a file.

interface CompressionCodec {
compress(input: Uint8Array): Uint8Array;
}

class GzipCodec implements CompressionCodec {
compress(input: Uint8Array): Uint8Array {
return input; // Compression details omitted
}
}

class BrotliCodec implements CompressionCodec {
compress(input: Uint8Array): Uint8Array {
return input; // Compression details omitted
}
}

class AssetWriter {
constructor(private readonly codec: CompressionCodec) {}

write(input: Uint8Array): Uint8Array {
return this.codec.compress(input);
}
}

const writer = new AssetWriter(new BrotliCodec());

AssetWriter knows nothing about the compression format. Adding a format does not touch AssetWriter either. As a design it works correctly.

Look at GzipCodec, though, and the whole class holds one thing: compress. A class with no state is a container that wraps a single function.

Replacing it with language features

In TypeScript you can pass a function itself as a value. The Handbook puts it this way.

Functions are the basic building block of any application, whether they're local functions, imported from another module, or methods on a class. They're also values, and just like other values, TypeScript has many ways to describe how functions can be called.2

A function type is written with an arrow.

type Compress = (input: Uint8Array) => Uint8Array;

const gzip: Compress = (input) => input; // Compression details omitted
const brotli: Compress = (input) => input; // Compression details omitted

function writeAsset(input: Uint8Array, compress: Compress): Uint8Array {
return compress(input);
}

const output = writeAsset(new Uint8Array([1, 2, 3]), brotli);

The interface and both classes are gone, leaving one type and two functions. The property of being swappable is preserved.

Looking at the parameters of writeAsset tells you that this function takes the compression format from outside. In the class version you had to look at the constructor to learn that. The dependency showing up in the shape of the call is a by-product of moving to functions.

When a strategy carries state or several operations

If compression and decompression are needed as a pair, the story changes. Carrying them around as two separate functions makes it possible to pass a mismatched combination. Bundle them into a single value and the pairing holds as long as that value is what gets passed.

type Codec = {
compress: (input: Uint8Array) => Uint8Array;
decompress: (input: Uint8Array) => Uint8Array;
contentEncoding: string;
};

const gzipCodec: Codec = {
compress: (input) => input,
decompress: (input) => input,
contentEncoding: 'gzip',
};

function writeAsset(input: Uint8Array, codec: Codec): {body: Uint8Array; encoding: string} {
return {body: codec.compress(input), encoding: codec.contentEncoding};
}

// Read back what was stored. The type fixes that compression and decompression use the same codec
function readAsset(body: Uint8Array, codec: Codec): Uint8Array {
return codec.decompress(body);
}

const asset = writeAsset(new Uint8Array([1, 2, 3]), gzipCodec);
const restored = readAsset(asset.body, gzipCodec);

Bundling the family into an object removes any way to write compression and decompression as different formats. What is used here is a plain object, not a class.

How this series judges it

Verdict: conditional. It comes down to whether things need bundling.

  • One operation and no state means a function — you just pass it as an argument. The type is one function type and the implementation is a function. No interface, no class.
  • Several operations that have to line up means an object — the Codec shape above. The type guarantees the family stays consistent.
  • A strategy that carries its own state means a closure or a class — this is when something has to be carried across calls: a connection, accumulated statistics, an internal buffer. A factory function returning a function that closes over the state expresses it too, so a class is only one of the options.

In all three, the receiving side such as AssetWriter stays the same. The choice is only about the shape on the strategy's side.

Rather than "class or function," working through "does anything need bundling" and then "is there state to carry" is the easier route to a decision.

What would overturn this judgment

Moving to functions can leave a strategy without a name. Nothing stops you from writing an anonymous function inline, as in writeAsset(input, (x) => x), so logs and error messages cannot report which strategy it was. If identifying the strategy matters in operations, that is a reason to move toward an object with a name.

One more thing: when the set of strategies gets picked from a configuration file or external input, you need a table mapping strings to strategies. That table takes the Record shape covered in Factory Method, where the Strategy and Factory discussions converge.

How this relates to existing articles

This site has several examples equivalent to Strategy, none of which name the pattern. This chapter approaches the same arrangement from the pattern's side, with a different subject from the existing articles.

  • The open-closed principle example in SOLID principles replaces string-based branching with an interface and several implementations. Its subject is the principle, and the pattern name never appears.
  • Classes and interfaces also has an example that injects behavior through the constructor.
  • Conditionals covers replacing a switch with a lookup table of objects. This chapter's "look a strategy up by string" is the same shape as the technique covered there.

Those articles handle "why do it this way" as a principle, and this chapter handles "which shape to pick in TypeScript."

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994). Strategy is catalogued there as a behavioral pattern. The summary of the intent is this guide's own; we have not checked it against the original text.

  2. Source: More on Functions (TypeScript Handbook), "Function Type Expressions".