2

Given I have a type MyType

type MyType = {
  foo: number;
  bar: string;
  baz: string;
}

I would like to define an array requiredFields that contains all keys of MyType whose value is a specified type T. Such that if an additional key of type T is later added to MyType, requiredFields should throw a type error because the new key is now missing.

That might look something like:

const numberFields: ExtractKeysByType<MyType, number> = ['foo'] // ✅
const stringFields: ExtractKeysByType<MyType, string> = ['bar'] // ❌ error - missing "baz"
Zach Olivare
  • 3,805
  • 3
  • 32
  • 45
  • 1
    Does this answer your question ? https://stackoverflow.com/questions/69464179/how-to-extract-keys-of-certain-type-from-object – emre-ozgun Feb 16 '23 at 21:45
  • It's close! That answer returns a string union type, whereas I'm looking for an array containing each of those strings. Just adding `[]` to the end isn't quite the same either, because it won't throw a type error if one of the strings is missing. – Zach Olivare Feb 16 '23 at 22:55
  • It is not possible for you to necessarily get `["bar", "baz"]` in the second case; you may get `["baz", "bar"]` instead. You can definitely get *a* tuple, but I don't see how that's useful. – jcalz Feb 17 '23 at 05:09
  • Sorry for the confusion, the order isn't important to me here. I rephrased the question to more clearly describe the desired outcome. – Zach Olivare Feb 17 '23 at 07:02
  • So you want an *exhaustive array* over the union of keys, as shown in [this playground link](https://tsplay.dev/WzGGrw). – jcalz Feb 17 '23 at 15:41

2 Answers2

0

By combining two other answers, I got the result I'm looking for. But the latter answer has a big DON'T DO THIS warning in front of it, so take this with the same precautions:

Step 1: How to extract keys of certain type from object

type ExtractKeyUnionByType<Obj, Type> = {
  [Key in keyof Obj]: Obj[Key] extends Type ? Key : never
}[keyof Obj]

This is close to what I was looking for, but returns a union type instead of a tuple type.

ExtractKeyUnionByType<MyType, string> // => 'bar' | 'baz'

And if you want to include optional types, add undefined to the union

type MyType = {
  foo?: number;
  bar: string;
  baz?: string;
  obj: MyOtherType
}

ExtractKeyUnionByType<MyType, string | undefined> // => 'bar' | 'baz' | undefined

Step 2: How to transform union type to tuple type (reminder, the author really doesn't want you to use this code)

// oh boy don't do this
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
  ? I
  : never
type LastOf<T> = UnionToIntersection<T extends any ? () => T : never> extends () => infer R
  ? R
  : never

// TS4.0+
type Push<T extends any[], V> = [...T, V]

// TS4.1+
type TuplifyUnion<T, L = LastOf<T>, N = [T] extends [never] ? true : false> = true extends N
  ? []
  : Push<TuplifyUnion<Exclude<T, L>>, L>
type BarAndBazTuple = TuplifyUnion<
  NonNullable<ExtractKeyUnionByType<MyType, string | undefined>>
> // => ['bar', 'baz']

And you can now strongly type a tuple, such that it will throw an error if you add any extra properties, or remove any properties.

const requiredFields: BarAndBazTuple = ['bar', 'baz'] // ✅

const missing: BarAndBazTuple = ['bar']               // ❌ error
const extra: BarAndBazTuple = ['bar', 'baz', 'other'] // ❌ error
Zach Olivare
  • 3,805
  • 3
  • 32
  • 45
  • It will also throw an error if you put them in the wrong order, and the order is completely up to the compiler and not something you can necessarily control... or at least not obviously. Look at [this example](https://tsplay.dev/WG22Xm), where seemingly unrelated code changes the order. I don't understand why you'd want to do this; is there really a use case where the order is important but you won't specify it yourself? – jcalz Feb 17 '23 at 05:06
  • @jcalz the order isn't important. What's important to my use case is for TS to throw an error if an item is missing from the array. If another string `zach: string` is added to `MyType`, I want `requiredFields` to throw an error because `zach` is now missing. I will clarity that in the original question! – Zach Olivare Feb 17 '23 at 06:24
  • You may get errors even if the array contains every field, because tuples have an order. So you don’t really want a tuple (or you want a union of every possible permutation of tuples) – jcalz Feb 17 '23 at 12:33
0

The goal of throwing a type error when a key of MyType of type T is missing from an array can be achieved more easily by first defining an object intermediateObj with the keys in question, and then using Object.keys() to transform that object into an array requiredFields.

By utilizing a union type and Record, an error can be thrown when a key is missing; as opposed to an Array<UnionType> which would only error if an incorrect property was added (not if one was missing).

First, two utility types. The first is borrowed from this wonderful other answer. The second modifies the first to also include optional properties.

type ExtractRequiredKeysByType<Obj, Type> = {
  [Key in keyof Obj]: Obj[Key] extends Type ? Key : never
}[keyof Obj]

type ExtractKeysByType<Obj, Type> = NonNullable<
  ExtractRequiredKeysByType<Obj, Type | undefined>
>

ExtractKeysByType can then be used to define an object which must contain all string keys from MyType

type StringKeys = ExtractKeysByType<MyType, string>

const intermediateObj: Record<StringKeys, true> = {
  bar: true,
  baz: true,
}

And finally that intermediate object can be transformed into the desired array via Object.keys()

const requiredFields = Object.keys(offerTermDateFieldsObj) as StringKeys[]

Now if someone comes along and adds another string value anotherString to MyType, an error will be thrown in the definition of intermediateObj

Property 'anotherString' is missing in type { bar: true; baz: true } but required in type Record<StringKeys, true>.

Zach Olivare
  • 3,805
  • 3
  • 32
  • 45