0

Is it possible to tell TypeScript that the type of the value passed to a function must possibly match a specific type, but can also be anything else, as long as it possibly matches the specified type. The function would then do further verification on this own.

Here's an example:

function isA(value: Possibly<"A">){ //note that I completely made up the "Possibly<>" thing
    return value === "A"
}

const x: "A"|"B" = "B"
isA(x) // should work, because x could be "A"

const y: "B"|"C" = "B";
isA(y) // should be rejected by the compiler, because there's no way for y to be "A"

Why do I need this? Well, if i write the function like this:

function isA(value: "A"){
    return value === "A"
}

Then I can only pass values to isA that are guaranteed to be "A", so there's no point in checking it.

And if I write it the following way:

function isA(value: string){
    return value === "A"
}
const y: "B"|"C" = "B";
isA(y)

Then the compiler does not complain, even though it's already clear at compile time, that it won't ever match.

I've made these examples as simple as possible on purpose. In practice I'd need it for much more complex, nested types.

Here's an example:

type A = {
    one: number;
    two: number;
}

function isA(value: Possibly<A>){
    return typeof value == "object" && "one" in value && "two" in value && typeof value.one === "number" && typeof value.two == "number";
}

isA({}) // should not work
isA({} as {one?: number; two?: number}) // should work
isA(undefined) // should not work
isA(undefined as undefined | A) // should work
Van Coding
  • 24,244
  • 24
  • 88
  • 132

1 Answers1

0

Yes it is possible.

NAЇVE EXAMPLE

const isA = <A extends string>(value: 'A' extends A ? A : never) => value === "A"

declare var x: 'A' | 'B'

isA(x) // ok

declare var y: "B" | "C"

isA(y) // expected error


Playground

'A' extends A ? A : never ----> means that if literal type A extends passed argument (if passed argument is a subtype of 'A') then return passed argument, otherwise - return never. Since never is unrepresentable, TS gives you an error.

MORE ADVANCED EXAMPLE

First of all, you should get rid of in operator, because it is not always narrows the type. Please see here, here and here. It is better to use hasProperty instead:

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

This is generic and type safe function to check whether property exists or not in the object.

Since, you want to check whether any part of union is assignable to desired type we need to know distinguish arguments with union type with single typed argument.

// credits goes to https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// credits goes to https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

COnsider this validation utility:

type Validation<Obj> =
    (IsUnion<Obj> extends true
        ? (NumberRecord extends Obj
            ? Obj : never)
        : (Obj extends NumberRecord
            ? Obj : never)
    )

If Obj is a union, check whether NumberRecord extends Obj. If yes, it means that some part of the union is assignable to NumberRecord and we can return Obj (allow the argument). If Obj is not a union of types, we are checking whether Obj is a subtype of NumberArgument. If yes, Obj is allowed, otherwise - return never. Hence, if argument has type undefined - it is disallowed, because it is neither a union with subtype of NumberRecord not subtype of NumberRecord.

Let's see how it works:


const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);


type NumberRecord = {
    one: number;
    two: number;
}

// credits goes to https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// credits goes to https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

type Validation<Obj> =
    (IsUnion<Obj> extends true
        ? (NumberRecord extends Obj
            ? Obj : never)
        : (Obj extends NumberRecord
            ? Obj : never)
    )

const isA = <
    Obj,
    >(value: Validation<Obj>) =>
    typeof value == "object" &&
    hasProperty(value, 'one') &&
    hasProperty(value, 'two') &&
    typeof value.one === "number" &&
    typeof value.two == "number";

/**
 * Ok
 */
isA({}) // should not work

isA({ one: 1, two: 2 }) // should work
isA(foo) // should work
isA({one:1,two:2,three:3}) // ok

/**
 * Errors
 */
isA(undefined) // should not work

declare var foo: undefined | NumberRecord


isA(42) // expected error
isA({one:1, two:'string'}) // expected error

Playground

In other words, we just negated all invalid types. You can see my article about type negation and type validation