Home > Software engineering >  TypeScript generic extending some Array type accepting unrelated type problem
TypeScript generic extending some Array type accepting unrelated type problem

Time:02-24

I have some function similar to the following mock.

// All properties in this type are optional.
interface MyType {
  a?: string
}

// The type of the return result of `cb` is kept as the final result type.
const f = <T extends ReadonlyArray<MyType>>(cb: (() => T)): T => cb()

const myTests1 = f(() => [1, {}]) // no error, but it really should
const myTests2 = f(() => [{}]) // no error
const myTests3 = f(() => [{}] as const) // no error
const myTests4 = f(() => [1]) // error
const myTests5 = f(() => [1, {}] as const) // error

TypeScript playground link

I intend for the cb function to return only items that are of MyTypes. The f function is sort of a type safe data model builder I intend to build that users can use to declare the complex MyType with type safety.

I know that TypeScript only really cares about the duck type and not the JS type strictly, so e.g. there would be no error when I say const x: {} = 1. And by experimenting, my typing issue was gone if any property in MyType is required.

But my question are:

  • How is the TS compiler OK with myTest1 but not with myTests4 or myTests5?
  • Is there a way to redefine the typing of the above function that would disallow users returning any items in cb that is not an object?

CodePudding user response:

The TypeScript type system isn't completely sound, so there are some inconsistencies that people occasionally trip over. Assignability/compatibility/subtyping should be transitive, so if X extends Y is true and Y extends Z is true then X extends Z should also be true. But this is sometimes violated in TypeScript:

let x: number = 1; 
let y: {} = x; // okay
let z: { a?: string } = y; // okay

z = x; // error!

Here you can see that while y = x is allowed, and z = x is allowed, the direct assignment z = x is prohibited. This inconsistency is responsible for the weirdness you're seeing. If the compiler notices that you are widening number to {a?: string} it will complain, but if there is an intermediate widening to {} it will not complain.

In

const myTests1 = f(() => [1, {}]) 

the compiler takes the array literal [1, {}] and infers it to have the type {}[]. This is inference to the "best common type" of number and {}, which is {}. At this point you've lost, since from then on the compiler makes sure that {} is assignable to {a?: string}, which it is, and no error occurs.

To some extent, this built-in inconsistency means that there's no way to prevent the kind of problem you're seeing in all situations. All you can hope to do is work around it and perhaps change your definition of f() to make the problem less likely to appear.


One way to do so might be as follows:

const f = <T extends MyType[]>(
  cb: (() => readonly [...T])
): readonly [...T] => cb()

Here I've replaced T with readonly [...T]. This is a variadic tuple type which is nearly the same as T. But when the compiler sees [...T] it takes it as a hint that it should try to infer a tuple type instead of an unordered array type.

And so then the array literal [1, {}] is inferred to be of the type [number, {}] instead of {}[]. And this preserves the number type long enough for the compiler to notice that it is not assignable to {a?: string}:

const myTests1 = f(() => [1, {}]) // error!
// ---------------------> ~
// Type 'number' has no properties in common with type 'MyType'.

There are observable differences between T and readonly [...T] (e.g., if T is not readonly then readonly [...T] will be) so this approach might not work for all possible use cases. And even with this approach, all it does is make the issue less likely; it doesn't eliminate it:

const myTests6 = f(() => {
  const ret = [1, {}]; // const ret: {}[]
  return ret;
}) // no error

Here, the type of ret is inferred to be {}[] inside the body of the callback function, independently of how f() is typed. Contextual typing can't reach back through the return ret line to the definition of ret.

Playground link to code

  • Related