typescriptconditional-typestypescript-template-literals

Why do conditional object types with a template literal key match any object regardless of its keys?


I want to make a Typescript utility type that extracts certain subtypes from a complex union of objects if they contain keys that match a template. For simplicity let's say I want to match object types that contain any key that starts with an underscore.

However, conditional types with a key with a template literal seem to always match any object, regardless of whether a matching key exists. Here's a simplified example:

// ❌ Conditional type on object with template literal key not working as expected
type HasTemplateProperty<T> = T extends { [key: `_${string}`]: string } ? true : false

type testA1 = HasTemplateProperty<{ _test: string }> // ✅ true, as expected
type testA2 = HasTemplateProperty<{ test: string }> // ❌ true, not expected
type testA3 = HasTemplateProperty<{}> // ❌ true, not expected
type testA4 = HasTemplateProperty<number> // ✅ false, as expected

// ✅ Conditional type on object with string literal key is working as expected
type HasLiteralProperty<T> = T extends { _test: string } ? true : false

type testB1 = HasLiteralProperty<{ _test: string }> // ✅ true, as expected
type testB2 = HasLiteralProperty<{ test: string }> // ✅ false, as expected
type testB3 = HasLiteralProperty<{}> // ✅ false, as expected
type testB4 = HasLiteralProperty<number> // ✅ false, as expected

// ✅ Conditional type template literal on its own is working as expected
type MatchesTemplate<T> = T extends `_${string}` ? true : false

type testC1 = MatchesTemplate<'_test'> // ✅ true, as expected
type testC2 = MatchesTemplate<'test'> // ✅ false, as expected
type testC3 = MatchesTemplate<'test_'> // ✅ false, as expected
type testC4 = MatchesTemplate<number> // ✅ false, as expected

Demo on Typescript Playground

I've seen some other discussions of unexpected behaviours in Typescript template literals and object keys, but I can't find anything about this specific behaviour. I also found this open issue on Typescript's GitHub which talks about computed property key names being unexpectedly widened to string, which may be relevant, but doesn't explain "testA3" where an object with no properties is considered a match.

Why is Typescript doing this? I can't see any way to get the expected behaviour of a Typescript utility type that matches objects containing keys that match a template like { _anything: '...', anythingElse: 123 } but not objects that don't contain such keys like { anything: '...', anythingElse: 123 } (I don't think mapped properties would work in my case because I want to get the match at the level of the object not at the level of individual properties).


Solution

  • What do you describe in the conditional type is an index signature. So it COULD contain property of key _${string} or COULDN'T. That's why {} gives true. Since {test: string} satisfies {} structurally it also gives true:

    type Check = { test: string } extends {} ? true : false; // true
    

    This is because { test: string } could be used as {} in TS.

    To make your conditional type work you could check that the both actual property keys and values satisfy your conditions: Playground

    type HasTemplateProperty<T> = keyof T extends `_${string}` ? T[keyof T] extends string ? true : false : false;
    
    type testA1 = HasTemplateProperty<{ _test: string }> // ✅ true, as expected
    type testA2 = HasTemplateProperty<{ test: string }> // ✅ false, as expected
    

    UPDATE

    According the OP it's needed to have at least 1 property with _${string} key. So we could use a mapped type to filter the _${string} keys and check whether they provide string property value:

    Playground

    type HasTemplateProperty<T> = 
        string extends T[keyof {[K in keyof T as K extends `_${string}`? K : never]: unknown}] ? true : false
    
    type testA1 = HasTemplateProperty<{ _test: string }> // ✅ true, as expected
    type testA2 = HasTemplateProperty<{ test: false, _test: string }> // ✅ true, as expected
    type testA3 = HasTemplateProperty<{ test: false }> // ✅ false, as expected
    type testA4 = HasTemplateProperty<{  }> // ✅ false, as expected
    type testA5 = HasTemplateProperty<{ _test: number }> // ✅ false, as expected