How can I infer optional properties in TypeScript?

Viewed 1828

I have the following type definition for a database schema:

type Schema<T> = {
  [K in keyof T]: SchemaType<T[K]>
}

interface SchemaType<T> {
  optional: boolean
  validate(t: T): boolean
}

Here's an object that implements it:

const userSchema = {
  name: {
    optional: true,
    validate(name: string) {
      return name.length > 0 && name.length < 30
    }
  }
}

I'd like to be able to infer the generic type T in Schema based on the userSchema object. This is doable without too much effort using TypeScript's infer keyword:

type extractType<T> = T extends Schema<infer X> ? X : null

// User is inferred as { name: string }
type User = extractType<typeof userSchema>

Even better, I'm able to infer the User type indirectly:

class Model<T> {
  constructor(private schema: Schema<T>) {}
}

// userModel is inferred as type Model<{name: string}>
const userModel = new Model(userSchema)

However, I would like to be able to infer User as { name?: String }, where name is optional because its optional property is set to true. Is there a way to do this?

Some related TypeScript features

Here are some potentially useful TypeScript features I ran across while working on this.

  1. TypeScript has discriminated unions, which is similar to the kind of type inference I'm trying to achieve.

  2. Although the optional field on the userSchema above is inferred as a boolean, it's possible to infer it as a boolean literal using as const.

    // userSchema is inferred as
    // { name: { optional: true, validate(name: string): boolean } }
    // instead of
    // { name: { optional: boolean, validate(name: string): boolean } }
    const userSchema = {
      name: {
        optional: true as const,
        validate(name: string) {
          return name.length > 0 && name.length < 30
        }
      }
    }
    

    Requiring this isn't ideal, but I'm not sure it's avoidable.

  3. I tried using conditional types like in the extractType implementation above to define Schema in a more limiting way:

    // The below interface essentially says:
    //   if T[K] is an optional property
    //     then -> optional is set to true
    //     else -> optional is set to false.
    // I was hoping TypeScript could reverse this when inferring the type:
    //   if optional is set to true
    //     then -> T[K] is an optional property
    //     else -> T[K] is a required property
    type Schema<T> = {
      [K in keyof T]: undefined extends T[K]
        ? OptionalSchemaType<T[K]> // Same as SchemaType but with optional: true
        : RequiredSchemaType<T[K]> // Same as SchemaType but with optional: false
    }
    
    const userModel = new Model(userSchema) // Error!
    

    Unfortunately, TypeScript's type inference isn't smart enough to be able to infer a type this complex.

I'm not sure if what I'm trying to achieve is possible, but any help or alternatives are very much appreciated. :)

1 Answers

First, you might well need to write true as const or true as true or something like that, since the compiler will tend to widen boolean literals to type boolean unless they are declared in a context in which the compiler expects a narrower type. One possible approach to avoid the explicit cast is to use a helper function that returns its input which it expects to be in such a narrower-type context:

const asSchema = <S extends Record<keyof S, { optional: B }>, B extends boolean>(
    s: S
) => s;

const userSchema = asSchema({
    name: {
        optional: true,
        validate(name: string) {
            return name.length > 0 && name.length < 30
        }
    },
    age: {
        optional: false,
        validate(age: number) {
            return age >= 0 && age < 200;
        }
    }
});

You can verify that the type of userSchema is:

/* const userSchema: {
    name: {
        optional: true;
        validate(name: string): boolean;
    };
    age: {
        optional: false;
        validate(age: number): boolean;
    };
} */

It may be worth it to use a helper function if you're planning on creating a lot of these things; or you may rather just write optional: false as false. Anyway, moving on:


I think you're going to need to calculate the type directly instead of relying on type inference. The direct approach requires a lot of explicitly writing out what parts of the type turn into what. Let's come up with some utility types:

The PartialPartial<T, K> type takes a type T and a set of its keys K and returns a new type like T except that all the properties indexed by K are optional. So, PartialPartial<{a: string, b: number}, "a"> is {a?: string, b: number}.

type PartialPartial<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K> extends
    infer O ? { [P in keyof O]: O[P] } : never;

The KeysMatching<T, V> type takes a type T and returns all the keys of T whose properties are assignable to V. So, KeysMatching<{a: string, b: number}, number> is "b".

type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];

And now for ExtractType<T>. Given a schema-like T, first map over each of its properties and pull out the first parameter to the validate() method. Then, take the whole result, and make optional all the properties whose optional property is true:

type ExtractType<T extends { [K in keyof T]: SchemaType<any> }> = PartialPartial<{
    [K in keyof T]: Parameters<T[K]['validate']>[0]
}, KeysMatching<T, { optional: true }>>

Testing it out on the userSchema above:

type UserThing = ExtractType<typeof userSchema>;
/* type UserThing = {
    name?: string | undefined;
    age: number;
} */

Looks good.


If we're giving up on type inference, this means you can't easily have Model<T> constructed by Schema<T> (at least not with standard class statements). One way around this is to have Model be generic in the schema type S, and use ExtractType<S> wherever you would want to use T. You could even put that T in the type definition, so you have Model<S, T> where S is the schema type and T is the extracted type:

class Model<S extends Record<keyof S, SchemaType<any>>, T extends ExtractType<S> = ExtractType<S>> {
    constructor(private schema: S) { }
}

const userModel = new Model(userSchema);
// S is typeof userSchema
// T is {name?: string, age: number}

My caveat about standard class statements: you could decide to describe the static side of Model via a ModelConstructor interface which allows you to specify a different sort of generic relationship from what is normally available between the constructor method and the instance type:

interface MyModelConstructor {
    new <S extends Record<keyof S, SchemaType<any>>>(schema: S): MyModel<ExtractType<S>>;
}
interface MyModel<T> {
    something: T;
}
const MyModel = Model as any as MyModelConstructor;

And test it:

const myUserModel = new MyModel(userSchema);
/* const myUserModel: MyModel<{
    name?: string | undefined;
    age: number;
}> */

Here you can see that new MyModel(userSchema) produces a MyModel<{name?: string, age: number}>. That's great, but you'll find yourself having to write out redundant interface declarations for the static/instance side of your class, and then assigning your other class constructor to it. The duplication might not be worth it; it's up to you.


Okay, I realize that was a lot of stuff. Hope it helps you. Good luck!

Playground link to code

Related