jackssrt
jackssrt

Reputation: 446

How to type a function that checks if argument extends type argument and returns the argument in typescript?

I want to type a function that checks that it's argument's type extends a type variable, and then returns the argument.
Like this:

declare function extendsA<I, X extends I>(argument:X): X

But when I use it, typescript complains that the function takes two type arguments. Or this:

declare function extendsB<I, X extends I = I>(argument:X): X

And that returns the wrong type.

Playground Link

How do I make typescript take the type of argument put it into the type variable X, ensure that it extends I and then return X?

A use case would be:

const recordOfNumberArray = extendsA<{[K: string]: readonly number[]}>({
"hello":[10,200]
} as const); // good
recordOfNumberArray. // with intellisense of all keys
const recordOfNumberArray = extendsA<{[K: string]: readonly number[]}>({
"hello":[10,200,"hi"]
} as const); // should error

Upvotes: 3

Views: 1590

Answers (1)

jcalz
jcalz

Reputation: 329598

Ah, you're trying to implement the so-called "satisfies" operator, as requested in microsoft/TypeScript#7481. The idea there is that you could write val satisfies Type, and there'd be a compiler error if val is not of type Type, but it does not widen val to type Type. Unfortunately such an operator does not yet exist in TypeScript, so you are resorting to a workaround. I'm going to rename extendsA to satisfies from here on.


So you can't easily implement satisfies as a single generic function of two type parameters, since TypeScript also lacks partial type parameter inference as requested in microsoft/TypeScript#26242. Either the compiler will infer all type parameters from the call, or you have to manually specify all the type parameters. As you discovered, a generic type parameter default cannot be used to give you partial inference, since all you'll get is the default if you leave out the parameter.

Instead, the normal way around this is to refactor to a curried function, where the function takes only the generic type parameter to be manually specified, and then it returns another function which takes the type parameter to be inferred. Like this:

// declare function satisfies<I>(): <X extends I>(argument: X) => X;

function satisfies<I>() {
    return <X extends I>(argument: X) => argument
}

And therefore you have to call it like this, with an extra function call step:

satisfies<{ [K: string]: readonly number[] }>()({
    "hello": [10, 200]
} as const); // good

It's not wonderful, although the pain can be lessened if you find yourself checking for the same I type more than once, since you can reuse the partially applied function:

const satisfiesRecordOfNumberArray =
    satisfies<{ [K: string]: readonly number[] }>();    

const recordOfNumberArray = satisfiesRecordOfNumberArray({
    "hello": [10, 200]
} as const); // good
recordOfNumberArray.hello // okay    

const recordOfNumberArray2 = satisfiesRecordOfNumberArray({
    "hello": [10, 200, "hi"]
} as const); // error! 
// Type 'readonly [10, 200, "hi"]' is not assignable to type 'readonly number[]'

The only other approach I can think of is to keep the two type parameters in one function, I and X, but add a dummy function parameter dummyI of type I. This is probably even worse than currying, since it forces you to provide a dummy value of type I, or pretend to:

// declare function satisfies<I, X extends I>(dummyI: I, argument: X): X;
function satisfies<I, X extends I>(dummyI: I, argument: X) { return argument }

const recordOfNumberArray = satisfies(null! as { [K: string]: readonly number[] }, {
    "hello": [10, 200]
} as const); // good

const recordOfNumberArray2 = satisfies(null! as { [K: string]: readonly number[] }, {
    "hello": [10, 200, "hi"]
} as const); // error! 
// Type 'readonly [10, 200, "hi"]' is not assignable to type 'readonly number[]'

That works, but now instead of satisfies<I>()(val) you have satisfies(null! as I, val). As I said, probably worse, although it's subjective. Ideally you wouldn't need any function calls and there'd be a built-in satisfies operator. Without a built-in operator, the jump from one function call to two function calls doesn't seem much worse than the jump from zero to one.

Playground link to code

Upvotes: 4

Related Questions