Skip to content

Type inference

The defining feature of clipse is that the options and arguments you declare are inferred into the action callback. This page explains how that works, so the compile-time errors you see make sense.

The goal

Given this code:

ts
new Clipse("app")
  .addOptions({ opt: { type: "string" }, verbose: { type: "boolean" } })
  .addArguments([{ name: "file" }])
  .action((args, opts) => {
    opts.opt;      // string
    opts.verbose;  // boolean
    args.file;     // string | undefined
    opts.nope;     // ❌ compile error
  });

opts and args are precisely typed, and a typo is a compile error — with no type annotations written by hand.

How it is built

The class carries two type parameters

ts
class Clipse<TOpts, TArgs> { … }

TOpts is the accumulated shape of the options, TArgs the shape of the arguments. A fresh new Clipse("app") starts with both empty.

addOptions grows TOpts

addOptions is generic over the exact object you pass and returns a Clipse whose TOpts is intersected with the newly inferred options:

ts
addOptions<const O extends ClipseOptions>(
  options: O,
): Clipse<TOpts & InferOptions<O>, TArgs>

The const type parameter preserves the literal shape of your object (so type: "boolean" stays the literal "boolean", not the widened string). InferOptions then maps each option to its value type:

ts
type OptionValue<O extends ClipseOption> =
  O["type"] extends "boolean" ? boolean : string;

type InferOptions<O extends ClipseOptions> = {
  [K in keyof O]: OptionValue<O[K]>;
};

addArguments grows TArgs

Similarly, addArguments uses a const parameter to capture literal argument names and maps them to keys:

ts
type InferArguments<A extends readonly ClipseArgument[]> = {
  [K in A[number]["name"]]: string;
};

action consumes both

Finally, action requires a callback typed from the accumulated shapes:

ts
action(a: ClipseFunction<TArgs, TOpts>): this

Because each builder step returns a Clipse with updated type parameters, by the time you call action the compiler knows every option and argument you declared.

Why unknown keys are rejected

The empty starting shape is a plain object type with no index signature. If it were Record<string, …>, an index signature would make opts.anything legal. Using an index-signature-free empty type means only the keys you actually declared exist on opts and args — so opts.nope is a genuine error.

The intentional soft spot

opts values are typed as always-present (string/boolean), even though an option marked optional: true may be absent at runtime. This is a deliberate trade-off: modelling optionality precisely in the type would complicate every signature for a case you can handle with a normal ?? fallback. If you need the "absent" state, mark the option optional: true and treat its value as possibly undefined in your own code.

Released under the MIT License.