TypeScript validation libraries and type inlining

By mary🐇 (@mary.my.id)
Published:

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 {}
}

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!