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 inType<A, B>
. It's an async operation that getsA
and returns aB
, 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 typedisplayName
is a short way to describe the type in the helpdefaultValue(): B
to allow the type to be optional and have a default value
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, returnprocess.stdin
like many Unix applications
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.