Skip to content

Template literal index does not transform the key and value types. #48983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
benjaminpjones opened this issue May 6, 2022 · 9 comments
Closed
Assignees
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@benjaminpjones
Copy link

Bug Report

I have a data store that uses prefixes to store different types of data. One of the types is "preferences".

data = {
  "preferences.a": string,
  "preferences.b": number
}

I have a getter interface for preferences that allows the user to query without the prefix:

type PreferencesSchema = {
  a: string,
  b: number
}
function getPreference<KeyT extends keyof PreferencesSchema>(key: KeyT): PreferencesSchema[KeyT] {
  return data[`preferences.${key}`];
} 

Unfortunately, this gives an error, because TS looks for a and b on the data instead of the actual indexes preferences.a and preferences.b. I understand template literal types are relatively new, but I think in this case, the compiler should see that the prefixed keys are being used and that the associated values are the same as the value types in the preference schema.

I found a similar looking issue: #13948, but I don't think this error can be attributed to type-widening.

🔎 Search Terms

template literal missing properties

🕗 Version & Regression Information

  • This changed between versions 4.3.5 and 4.4.4 (4.3.5 still gives a noImplicitAny error, but the introduction of template literal types makes this issue arise even without strict checks)

⏯ Playground Link

Playground link with relevant code

💻 Code

type PreferencesSchema = {
  a: string,
  b: number
}

interface DataSchema {
  "preferences.a": string,
  "preferences.b": number
}

const data: DataSchema = {
  "preferences.a": "hello world",
  "preferences.b": 12345
}

// the issue
function getPreference<KeyT extends keyof PreferencesSchema>(key: KeyT): PreferencesSchema[KeyT] {
  return data[`preferences.${key}`];
} 

// workaround
function workingGetPreference<KeyT extends keyof PreferencesSchema>(key: KeyT): DataSchema[`preferences.${KeyT}`] {
  return data[`preferences.${key}`];
}

🙁 Actual behavior

An error occurs:

Type 'DataSchema[`preferences.${KeyT}`]' is not assignable to type 'PreferencesSchema[KeyT]'.
  Type 'DataSchema' is missing the following properties from type 'PreferencesSchema': a, b

🙂 Expected behavior

TypeScript should recognize that preferences.a/preferences.b is being used as an index, not a/b. Therefore it shouldn't matter that DataSchema is missing a and b as properties.

@jcalz
Copy link
Contributor

jcalz commented May 6, 2022

I think the error message is misleading. Looks like the compiler can't see that DataSchema[`preferences.${K}`] and PreferencesSchema[K] are the same type. It reports the failure in the mechanism available for comparing two generic indexed access types, which is presumably comparing both the object types and the key types.

As evidence that the compiler is definitely able to recognize that you are indexing into DataSchema with keyof DataSchema, note that there is no such error for a non-generic version of your function:

function getPreferenceNonGeneric(key: keyof PreferencesSchema): PreferencesSchema[keyof PreferencesSchema] {
  return data[`preferences.${key}`]; // okay
}

So the problem is the abstract relationship between T[K] and {[P in keyof T as F<P>]: T[P]}[F<K>] for generic K.

It seems like this is related to #30581, but I can't see a reasonable way to make the fix in #47109 help here.

@RyanCavanaugh
Copy link
Member

This is broken in the same way in 4.3 if you add an as const

   return data[`preferences.${key}` as const];

The error message is indeed super wrong. It seems like we're trying to relate T[K] to U[K] via the relationship between T and U but there's just no reason to do that.

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label May 6, 2022
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.8.0 milestone May 6, 2022
@Andarist
Copy link
Contributor

Andarist commented May 9, 2022

So the reported error here comes from this check and this check doesn't have a chance to be satisfied here:
https://github.dev/microsoft/TypeScript/blob/1071240907ab7aae63ecc9c0bbde42aa51dc7669/src/compiler/checker.ts#L19410-L19416

This is not the only check that is performed here because if this fails then the constraint-based fallback is used~:
https://github.dev/microsoft/TypeScript/blob/1071240907ab7aae63ecc9c0bbde42aa51dc7669/src/compiler/checker.ts#L19440

This check doesn't succeed either because the computed constraint is never and that creates an error like this:

Type 'DataSchema[`preferences.${KeyT}`]' is not assignable to type 'PreferencesSchema[KeyT]'.
  Type 'DataSchema[`preferences.${KeyT}`]' is not assignable to type 'never'.
    Type 'string | number' is not assignable to type 'never'.
      Type 'string' is not assignable to type 'never'.

There is some heuristic in place that makes the first error to be displayed here - but this somewhat doesn't matter as the second one is almost as confusing as the first one :p
https://github.dev/microsoft/TypeScript/blob/1071240907ab7aae63ecc9c0bbde42aa51dc7669/src/compiler/checker.ts#L19445

So... how this potentially could be fixed? I think that instead of checking isRelatedTo(source.objectType, target.objectType) and isRelatedTo(source.indexType, target.indexType) we should actually check this property value by property value with instantiated indexTypes, using all possible permutations of the involved type arguments (received from the base constraints of those indexTypes). I'm not entirely sure if this logic could just replace the existing logic but I don't see why it couldn't

@ahejlsberg
Copy link
Member

This is working as intended. What's missing in the example is the presence of a mapped type to demonstrate the relationship between PreferencesSchema and DataSchema. Once you add that it works as intended due to #47109:

type PreferencesSchema = {
  a: string,
  b: number,
}

type DataSchema<T> = { [P in keyof T as `preferences.${P & string}`]: T[P] }

const data: DataSchema<PreferencesSchema> = {
  "preferences.a": "hello world",
  "preferences.b": 12345
}

function getPreference<K extends keyof PreferencesSchema>(key: K) {
  return data[`preferences.${key}`];
} 

@ahejlsberg ahejlsberg added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Bug A bug in TypeScript labels May 22, 2022
@ahejlsberg ahejlsberg removed this from the TypeScript 4.8.0 milestone May 22, 2022
@Andarist
Copy link
Contributor

Andarist commented May 22, 2022

@ahejlsberg how the demonstrated relationship helps conceptually cases like this? type of data is concrete~ and it shouldn't matter if it's being computed using a reference to PreferencesSchema or not. I mean - DataSchema<PreferencesSchema> should work exactly the same as the "inlined" version that is present in the repro.

I also think that your example is closer to the presented workaround (workingGetPreference in the repro) - it just doesn't declare the return type. Once we annotate the return type to your snippet then we end up with almost the same error as in the repro case:

type PreferencesSchema = {
  a: string,
  b: number,
}

type DataSchema<T> = { [P in keyof T as `preferences.${P & string}`]: T[P] }

const data: DataSchema<PreferencesSchema> = {
  "preferences.a": "hello world",
  "preferences.b": 12345
}

function getPreference<K extends keyof PreferencesSchema>(key: K): PreferencesSchema[K] {
  // Type 'DataSchema<PreferencesSchema>[`preferences.${K}`]' is not assignable to type 'PreferencesSchema[K]'.
  //   Type 'DataSchema<PreferencesSchema>' is missing the following properties from type 'PreferencesSchema': a, b
  return data[`preferences.${key}`];
} 

TS playground

Note that the annotated return type is referring to the PreferencesSchema and without the annotation, the return type refers to DataSchema<PreferencesSchema>. The claim here is that those should, in here, behave in the same - I've reexamined this code snippet and it feels like a valid expectation, unless I'm missing how it's fundamentally invalid

@ahejlsberg
Copy link
Member

ahejlsberg commented May 23, 2022

Hmm, I missed that the type annotation is using an indexed access on the original type instead of the mapped type. I think it is bit of a stretch to expect that to type check. Specifically, given non-generic object types A and B and a generic type K extends keyof A, you are expecting that an indexed access B[M<K>] be assignable to A[K], where M is some higher-order mapping. In order for that to type check, we'd have to prove that M is an isomorphic (i.e. reversible) mapping, and that keyof A is a subtype of M<keyof B>, and for each property P in keyof B, B[M<K>] is assignable to A[K]. It's probably possible to do this, but I'm not sure the complexity is worth it. Seems to me the example I give above is satisfactory. In particular since, with the mapped type, you're not repeating yourself in two types.

@Andarist
Copy link
Contributor

Ye, I see how it gets pretty gnarly for the compiler - from the user's perspective it isn't that obvious that this might be the limitation (in such cases it usually isn't obvious because we, users, have no idea how the compiler works under the hood) and the reported error is quite confusing. Perhaps at least this error could be improved?

Seems to me the example I give above is satisfactory. In particular since, with the mapped type, you're not repeating yourself in two types.

Right, this is a good example of how to make the relationship more explicit and "sync" both of those schema types over time.

@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@Andarist
Copy link
Contributor

Hm, should the bot close after 3 days since the last comment? That seems to be... pretty fast 😬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

6 participants