TypeScript validation libraries and type inlining

@mary.my.id

one of the best things to emerge from the TypeScript ecosystem has been the plethora of validation libraries like Zod or Valibot. these libraries allow you to get runtime validation and type inference from a single schema definition:

import * as v from 'valibot';

export const personSchema = v.object({
  name: v.string(),
  age: v.optional(v.number()),
});

export type Person = v.InferInput<typeof personSchema>;
//          ^? { name: string; age?: number }

unfortunately, these libraries don't handle well in scenarios where you have a large set of schemas that reference each other. unlike interfaces, these types are not anchored to a declaration—they will always be inlined wherever they're referenced, resulting in something like this:

nightmare code

this schema defines AT Protocol's lexicon documents, but due to inlining, this one single exported schema is 2,943 lines long—approximately 146,870 characters (with most whitespaces removed), insane because this doesn't even cover the schemas it depends on which I also export. I find this to be a massive problem because the resulting declaration files becomes bloated and it's hard to read and see what's going on, big deal for a library!

fortunately, I've found a solution to this problem, though it's somewhat awkward. you should declare your schemas like this:

import * as v from 'valibot';

const _lexBoolean = v.strictObject({
  type: v.literal('boolean'),
  description: v.optional(v.string()),
  default: v.optional(v.boolean()),
  const: v.optional(v.boolean()),
});

export const lexBoolean = _lexBoolean as lexBoolean.$schema;
export declare namespace lexBoolean {
  export {};

  type $schematype = typeof _lexBoolean;
  export interface $schema extends $schematype {}
}
  1. store the actual definition of the schema in a different variable (here I'm prefixing it with an underscore).
  2. create a namespace, this is optional, but helps to tidy up the mess especially since you're going to have to do this for every object schema. we're doing declare namespace here to avoid TypeScript generating any runtime code for it, and because of this we'll also have to add an empty export {} specifier otherwise all declared types gets exported (which we don't want for the typeof we're about to do)
  3. grab the type from that schema and export an interface that extends from it. we're not doing extends typeof because that's invalid syntax.
  4. export a variable pointing to the schema, but add an assertion that the type is of the interface we created.

now, instead of being inlined, TypeScript can just reference lexBoolean.$schema, and your resulting declaration file is much cleaner, making it suitable for library publishing.

when we apply this technique to the AT Protocol lexicon documents from earlier, the output becomes much more reasonable:

// ...

declare const _lexiconDoc: v.StrictObjectSchema<{
  readonly lexicon: v.LiteralSchema<1, undefined>;
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, undefined>]>;
  readonly revision: v.OptionalSchema<integer.$schema, undefined>;
  readonly description: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
  readonly defs: v.RecordSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, undefined>]>, lexUserType.$schema, undefined>;
}, undefined>;
export declare const lexiconDoc: lexiconDoc.$schema;
export declare namespace lexiconDoc {
  export {};
  type $schematype = typeof _lexiconDoc;
  export interface $schema extends $schematype {
  }
}

hope you find this technique useful in your TypeScript libraries or projects!

mary.my.id
mary🐇

@mary.my.id

🏳️‍⚧️🇮🇩 she/it · 22
web dev @buttondown.com

dms open but rarely checked
mary.my.id for projects and other socials

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)