2
  export type Parser = NumberParser | StringParser;

  type NumberParser = (input: string) => number | DiplomacyError;
  type StringParser = (input: string) => string | DiplomacyError;

  export interface Schema {
    [key: string]: Parser | Schema;
  }

  export type RawType<T extends Schema> = {
    [Property in keyof T]: T[Property] extends Schema
      ? RawType<T[Property]>
      : ReturnType<Exclude<T[Property], Schema>>;
  };


  // PersonSchema is compliant the Schema interface, as well as the address property
  const PersonSchema = {
    age: DT.Integer(DT.isNonNegative),
    address: {
      street: DT.String(),
    },
  };

  type Person = DT.RawType<typeof PersonSchema>;

Sadly type Person is inferred as:

type Person = {
    age: number | DT.DiplomacyError;
    address: DT.RawType<{
        street: StringParser;
    }>;
}

Instead I would have liked to get:

type Person = {
    age: number | DT.DiplomacyError;
    address: {
        street: string | DT.DiplomacyError;
    };
}

What am I missing?

Davide Valdo
  • 693
  • 7
  • 18
  • 1
    It looks like those are the same type; just displayed differently. I will verify when I get a chance. There are ways to convince the compiler to expand the type... not really equipped to answer this minute as I’m on mobile. – jcalz Apr 10 '21 at 14:40
  • Assigning a string to address.street does in fact work – Davide Valdo Apr 10 '21 at 14:43

1 Answers1

1

The difference between the Person displayed and the type you expected is pretty much just cosmetic. The compiler has a set of heuristic rules it follows when evaluating and displaying types. These rules have changed over time and are occasionally tweaked, such as the "smarter type alias preservation" support introduced in TypeScript 4.2.

One way to see that the types are more or less equivalent is to create both of them:

type Person = RawType<PersonSchema>;
/*type Person = {
    age: number | DiplomacyError;
    address: RawType<{
        street: StringParser;
    }>;
}*/

type DesiredPerson = {
    age: number | DiplomacyError;
    address: {
        street: string | DiplomacyError;
    };
}

And then see that the compiler considers them mutually assignable:

declare let p: Person;
let d: DesiredPerson = p; // okay
p = d; // okay

The fact that those lines did not result in a warning means that, according to the compiler, any value of type Person is also a value of type DesiredPerson, and vice versa.

So maybe that's enough for you.


If you really care about how the type is represented, you can use techniques such as described in this answer:

// expands object types recursively
type ExpandRecursively<T> = T extends object
    ? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
    : T;

If I compute ExpandRecursively<Person>, it walks down through Person and explicitly writes out each of the properties. Assuming that DiplomacyError is this (for want of a minimal reproducible example in the question):

interface DiplomacyError {
    whatIsADiplomacyError: string;
}

Then ExpandRecurively<Person> is:

type ExpandedPerson = ExpandRecursively<Person>;
/* type ExpandedPerson = {
    age: number | {
        whatIsADiplomacyError: string;
    };
    address: {
        street: string | {
            whatIsADiplomacyError: string;
        };
    };
} */

which is closer to what you want. In fact, you could rewrite RawType to use this technique, like:

type ExpandedRawType<T extends Schema> = T extends infer O ? {
    [K in keyof O]: O[K] extends Schema
    ? ExpandedRawType<O[K]>
    : O[K] extends (...args: any) => infer R ? R : never;
} : never;

type Person = ExpandedRawType<PersonSchema>
/* type Person = {
    age: number | DiplomacyError;
    address: {
        street: string | DiplomacyError;
    };
} */

which is exactly the form you wanted.

(Side note: there is a naming convention for type parameters, as mentioned in this answer. Single capital letters are preferred over whole words, so as to distinguish them from specific types. Hence, I have replaced Property in your examples with K for "key". It might seem paradoxical, but because of this convention, K is more likely to be immediately understood by TypeScript developers to be a generic property key than Property is. You are, of course, free to continue using Property or anything else you like; it's just a convention, after all, and not some sort of commandment. But I just wanted to point out that the convention exists.)

Playground link to code

jcalz
  • 125,133
  • 11
  • 145
  • 170
  • I'm still having a hard time understanding why I need to "manually" infer the O type, but it did indeed work... DiplomacyError is an enum mapping to strings and it doesn't seem to preserve the type alias, seems like the compiler is doing `string | DiplomacyError -> string | string -> string` (Thank you so much.) – Davide Valdo Apr 12 '21 at 21:25
  • But that would be a whole different question :) – Davide Valdo Apr 12 '21 at 22:55