Custom Types

Not all command line arguments are strings. You sometimes want integers, UUIDs, file paths, directories, globs...

Note: this section describes the ReadStream type, implemented in ./example/test-types.ts

Let's say we're about to write a cat clone. We want to accept a file to read into stdout. A simple example would be something like:

// my-app.ts

import { command, run, positional, string } from 'cmd-ts';

const app = command({
  /// name: ...,
  args: {
    file: positional({ type: string, displayName: 'file' }),
  },
  handler: ({ file }) => {
    // read the file to the screen
    fs.createReadStream(file).pipe(stdout);
  },
});

// parse arguments
run(app, process.argv.slice(2));

That works well! We already get autocomplete from TypeScript and we're making progress towards developer experience. Still, we can do better. In which ways, you might think?

  • Error handling is non existent, and if we'd implement it in our handler it'll be out of the command line argument parser context, making things less consistent and pretty.
  • It shows we lack composability and encapsulation — we miss a way to share and distribute "command line" behavior.

💡 What if we had a way to get a Stream out of the parser, instead of a plain string?

This is where cmd-ts gets its power from,

Custom Type Decoding

Exported from cmd-ts, the construct Type<A, B> is a way to declare a type that can be converted from A into B, in a safe manner. cmd-ts uses it to decode the arguments provided. You might've seen the string type, which is Type<string, string>, or, the identity: because every string is a string. Constructing our own types let us have all the implementation we need in an isolated and easily composable.

So in our app, we need to implement a Type<string, Stream>, or — a type that reads a string and outputs a Stream:

// ReadStream.ts

import { Type } from 'cmd-ts';
import fs from 'fs';

// Type<string, Stream> reads as "A type from `string` to `Stream`"
const ReadStream: Type<string, Stream> = {
  async from(str) {
    if (!fs.existsSync(str)) {
      // Here is our error handling!
      throw new Error('File not found');
    }

    return fs.createReadStream(str);
  },
};
  • from is the only required key in Type<A, B>. It's an async operation that gets A and returns a B, or throws an error with some message.
  • Other than from, we can provide more metadata about the type:
    • description to provide a default description for this type
    • displayName is a short way to describe the type in the help
    • defaultValue(): B to allow the type to be optional and have a default value
    • onMissing(): B | Promise<B> to provide a dynamic fallback when the argument is not provided (used as fallback if defaultValue is not provided)

Using the type we've just created is no different that using string:

// my-app.ts

import { command, run, positional } from 'cmd-ts';

const app = command({
  // name: ...,
  args: {
    stream: positional({ type: ReadStream, displayName: 'file' }),
  },
  handler: ({ stream }) => stream.pipe(process.stdout),
});

// parse arguments
run(app, process.argv.slice(2));

Our handler function now takes a stream which has a type of Stream. This is amazing: we've pushed the logic of encoding a string into a Stream outside of our implementation, which free us from having lots of guards and checks inside our handler function, making it less readable and harder to test.

Now, we can add more features to our ReadStream type and stop touching our code which expects a Stream:

  • We can throw a detailed error when the file is not found
  • We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream
  • We can see if the string is -, and when it happens, return process.stdin like many Unix applications

Custom Types with onMissing

Custom types can also provide dynamic defaults using onMissing. This is useful when you want the type itself to determine what happens when no argument is provided:

const ConfigFile: Type<string, Config> = {
  async from(str) {
    if (!fs.existsSync(str)) {
      throw new Error(`Config file not found: ${str}`);
    }
    return JSON.parse(fs.readFileSync(str, 'utf8'));
  },
  
  displayName: 'config-file',
  
  async onMissing() {
    // Look for config in standard locations when not provided
    const candidates = [
      './config.json',
      path.join(os.homedir(), '.myapp', 'config.json'),
      '/etc/myapp/config.json'
    ];
    
    for (const candidate of candidates) {
      if (fs.existsSync(candidate)) {
        console.log(`Using config from: ${candidate}`);
        return JSON.parse(fs.readFileSync(candidate, 'utf8'));
      }
    }
    
    // Return default config if none found
    return { debug: false, verbose: false };
  },
};

And the best thing about it — everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at io-ts-types, for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do.