sindresorhus / ow

Function argument validation for humans
https://sindresorhus.com/ow/
MIT License
3.8k stars 105 forks source link

should `ow.string.equals` be narrowing the type to specific strings? #235

Open atomanyih opened 2 years ago

atomanyih commented 2 years ago

Problem

Use case: validating tagged json objects coming over the wire

type Carrot = {
  type: 'carrot';
};

type Hotdog = {
  type: 'hotdog';
};

type Food = Hotdog | Carrot;

function validateFood(mightBeFood: unknown): Food {
  ow(
    mightBeFood,
    ow.any(
      ow.object.exactShape({
        type: ow.string.equals('carrot'),
      }),
      ow.object.exactShape({
        type: ow.string.equals('hotdog'),
      }),
    ),
  );

  return mightBeFood;
  // TS2322: Type '{ type: string; } | { type: string; }' is not assignable to type 'Food'.
  //   Type '{ type: string; }' is not assignable to type 'Food'.
  //     Type '{ type: string; }' is not assignable to type 'Hotdog'.
  //       Types of property 'type' are incompatible.
  //         Type 'string' is not assignable to type '"hotdog"'.
}

Current workaround:

As far as I could tell, there's no built in way to do this as StringPredicate#addValidator and StringPredicate#validate don't seem to let you change the type contained in the predicate.

So, I created a custom predicate:

// exactString.ts

import { Predicate } from 'ow';

class ExactStringPredicate<S extends string> extends Predicate<S> {
  expected: S;

  constructor(expected: S) {
    super('string');
    this.expected = expected;
  }

  equals(): Predicate<S> {
    return this.addValidator({
      message: (value, label) =>
        `Expected ${label} to be \`${this.expected}\`, got \`${value}\``,
      validator: (value): value is S => {
        return value === this.expected;
      },
    });
  }
}

// these shenanigans are to make it so that we don't have to new this up every time
const exactString = <S extends string>(expected: S) =>
  new ExactStringPredicate(expected).equals();

export default exactString;

Possible solutions:

A. Generify ow.string.equals

Currently this doesn't allow any further narrowing:

class StringPredicate extends Predicate<string> {
    equals(expected: string): this;
}

It could possibly be:

class StringPredicate extends Predicate<string> {
    equals<S extends string>(expected: S): Predicate<S>;
}

This does prevent any further chaining onto that predicate, but one could argue that a string matching equals cannot be further validated. Certainly none of the other included validators can give you any more useful information

// this no longer works
ow.string.equals('hotdog').includes('dog)

B. Generify StringPredicate

If one wanted to maintain chaining, could do something like this:

class StringPredicate<S extends string> extends Predicate<S> {
    equals<S2 extends string>(expected: S2): StringPredicate<S2>;
}

Thus the returned type would still be a StringPredicate.

C. Some other better solution ? ? ?

YElyousfi commented 2 years ago

I agree with this I think the same can be concept can be applied to .oneOf

// A
class StringPredicate extends Predicate<string> {
    oneOf<S extends string>(expected: S[]): Predicate<S>;
}
// B
class StringPredicate<S extends string> extends Predicate<S> {
    oneOf<S2 extends string>(expected: S2[]): StringPredicate<S2>;
}