Alex l.
Alex l.

Reputation: 273

Complex type for describing object schema with plain object

Here's the type I come up with so far

type Schema<T extends {}> = {
    [K in keyof T]: T[K] extends {} ? Schema<T[K]> : JsType
}

type JsType = "boolean" | "string" | "function" | "number" | "symbol" | "undefined"

it doesn't work. Expected behaviour - kind, amount and isHalfed should be of type JsType (because they don't extend {} I assumed). Instead they act weirdly and isHalfed becomes Schema<boolean> but kind and amount become string and number respectively.

interface Cup {
    content: {       
        kind: string     
        amount: number   
    }                    
    isHalfed: boolean    
}

const cupSchema: Schema<Cup> = {
    content: {             
        kind: "string"   
        //Error: Type 'string' is not assignable to type 'number'.
        amount: "number" 
    }

    // Error: Type 'string' is not assignable to type 'Schema<boolean>'.
    isHalfed: "boolean"    
}

Even if it would work - it would not be perfect. In ideal world property kind would have string literal type "string", amount - "number" and isHalfed - "boolean" (all string literal types). This level of type precision is desired but not necessary.

My implementation seems to make typescript think that all shallow nested properties extend {} - so they have type of Schema, but deeply nested properties straight up ignore this behaviour (despite being wrapped in Schema type)

Is there a way to fix this issue? And if possible - is there a way to make it work even better (like I described above) ?

Upvotes: 1

Views: 180

Answers (1)

jcalz
jcalz

Reputation: 329388

The {} type does not exclude primitives like string or number. The only values not assignable to the empty type {} are null and undefined.

It may be confusing, but in TypeScript, "object" types surrounded by curly braces (such as interfaces) do not automatically prohibit primitives. If a type can be indexed into like an object (e.g., ("hello").toUpperCase() or (123).toFixed(2)), then it can be assignable to an object type. Since {} does not have any required members, then everything except for null and undefined (which throw errors if you index into them) will be assignable to it. See this FAQ entry for more information.


It's possible you want to use the object type instead, which was specifically introduced to match only non-primitive objects. If I make that change, your code compiles:

type Schema<T extends {}> = {
  [K in keyof T]: T[K] extends object ? Schema<T[K]> : JsType
}


const cupSchema: Schema<Cup> = {
  content: {
    kind: "string",
    amount: "number"
  },
  isHalfed: "boolean"
} // okay

Although, as you said, it's still not ideal, since it looks like

type SchemaCup = {
  content: {
    kind: JsType;
    amount: JsType;
  };
  isHalfed: JsType;
}

Perhaps the "ideal" version would look like this:

type Schema<T> =
  T extends boolean ? "boolean" :
  T extends string ? "string" :
  T extends Function ? "function" :
  T extends number ? "number" :
  T extends bigint ? "bigint" :
  T extends symbol ? "symbol" :
  T extends undefined ? "undefined" :
  {
    [K in keyof T]: Schema<T[K]>
  };

which uses a chain of conditional types to pick out the exact string literal type for each primitive, and then falls back to the recursive object traversal if it's not one of those primitive types.

Let's see what we get for Schema<Cup> now:

/* const cupSchema: {
     content: {
       kind: "string";
       amount: "number";
     };
     isHalfed: "boolean";
   } */

Looks good!

Playground link to code

Upvotes: 1

Related Questions