Typescript: Type unwrapping properties of specific generic type

80 views Asked by At

I want to achieve a type that extracts the inner-type of all properties being a Model<infer T>, but leaves others untouched; something like this:

MyType<{ a: number, b: Model<string> }> // => { a: number, b: string }

I thought it would be as simple as this:

type MyType<T> = {
  [P in keyof T]: T[P] extends Model<infer R> ? R : T[P];
};

but when testing it like this:

const fnForTesting = <T>(obj: T): MyType<T> => {
  return null!;
};

const result = fnForTesting({
  name: "Ann",
  age: new Model<number>(),
});

const a = result.name; // unknown :( -> should be string
const b = result.age; // number -> works as expected

Does anybody know why "normal" properties are not recognized correctly, while the Model properties are? And how do I fix this? Thanks in advance!

2

There are 2 answers

1
mochaccino On BEST ANSWER

Since your Model class is empty, Model<T> is the same as {} (the empty object type). So when you use T[P] extends Model<infer R>, TypeScript cannot infer R properly, so it uses unknown. The reason why TypeScript doesn't back off and fallback to T[P] is because all types are assignable to {} except for null and undefined. Basically,

type T = string extends {} ? true : false;
//   ^? true

Now notice that when I add a property to your Model class, to make it non-empty, your original code works:

class Model<T>{
    value!: T;

    constructor() { }
}

// ...

const a = result.name; // string
//    ^?
const b = result.age; // number 
//    ^?
const c = result.date; // date 
//    ^?

This is because there is now a clear, structural difference between a string (or date) and a Model<T>, and TypeScript can now check if T[P] is a model and infer the type.

Playground (changed Model)


Filly's code works you're essentially checking if an empty object is assignable to a string (or date), which is invalid. This acts as a guard before trying to infer the inner type of the model.

type T = {} extends string ? true : false;
//   ^? false

That means Model<unknown> extends T[P] only triggers if T[P] is an empty object or Model<T>.

Playground (Filly's solution)

(you don't need Filly's solution if your Model class is structurally different from the {} type)

1
Pritam Mohanty On

Your implementation only checks if the property type extends Model, but if it does not, it simply returns the original type without any modifications.

To fix this, you can update the MyType type to include a catch-all case that returns the original type for any properties that do not extend Model:

type MyType<T> = {
  [P in keyof T]: T[P] extends Model<infer R> ? R : T[P];
} & { [P in Exclude<keyof T, keyof Model<any>>]: T[P] };

This updated implementation adds a second mapped type that includes all properties that are not a Model. The Exclude<keyof T, keyof Model> expression is used to get the keys that are not a part of Model. The & operator is used to combine the two mapped types.