I use type guards and the `io-ts` package to get both compile-time and runtime type checking:
import * as t from 'io-ts';
/* -------- Runtime type definitions -------- */
export const FooSchema = t.type({
someStringKey: t.string,
someNumericKey: t.number,
someLiteralKey: t.union([t.literal(1), t.literal(8675309)])
someOtherType: t.type({
anotherNumber: t.number,
yetAnotherType: BarSchema
})
})
export const BarSchema = t.type({
...
})
/* ---- Run-of-the-mill Typescript types ---- */
export type Foo = t.Typeof<typeof FooSchema>;
export type Bar = t.Typeof<typeof BarSchema>;
// Static usage
export const Baz: Foo = { someStringKey: 5 } // Compile error! Missing keys/etc
// Runtime usage
export const isFooType = (input: any): input is Foo =>
FooSchema.validate(input)
if (!isFooType(someUserInput)) return server.send(404);
You can enforce exact type shape, or just ignore excess keys, etc. It's pretty handy, and saves me
so much work maintaining two type systems, a static one for development and a runtime one for production.
All of the typical caveats apply: consider runtime performance cost, recognize that deep/nuanced types will require a deeper/nuanced understanding of io-ts, there are limitations, alternative libraries with different tradeoffs, and so on.
I like it. :)