question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Discussion: Compile-Time TypeScript type validation

See original GitHub issue

The current type definitions located at @types/superstruct are good. However, they do not check the type of your struct at compile time, leading to poorer autocomplete as the shape of your struct is not known to TypeScript. A major reason for this is TypeScript’s widening of string literals in non-constant contexts. For example, the type of the following struct definition:

const def = {
  someProp: 'number'
}

is known as:

{someProp: string}

rather than

{someProp: 'number'}

This makes it near impossible to write any kind of mapped type that would be valid to your struct. In my research, I have discovered two possible solutions rather than just defaulting to any: a shape-only type and more specific struct definitions.

With a shape only type, upon constructing a struct with this definition:

const definition = {
  someProp: 'number',
  someObject: {
    anotherProp: 'boolean?'
  }
}

you would receive a validation function that has the following type:

(objectToTest: any) => {someProp: any, someObject: {anotherProp: any}}

This would at least enable autocomplete with your struct’s properties, preventing typos at compile time. This does not protect against a property possibly being null (for example, struct.someObject.anotherProp can be null in the above example) nor does it actually enforce the types (i.e. number) you’ve already defined in your struct.

The other option is to use string literal narrowing in your struct definitions, or const enums. For example, if we define a const enum like this:

const enum Types {
  String = 'string',
  OptionalString = 'string?'
  Number = 'number',
  OptionalNumber = 'number?'
}

it becomes possible to write a mapped type such that a struct definition such as this:

const definition = {
  someProp: Types.Number
}

returns a struct function with the following type:

(objectToTest: any) => {someProp: number}

This also works if you force the compiler to not widen the type. The following definition will produce the same type:

const definition = {
  someProp: 'number' as 'number'
}

The nice thing about using a const enum is that is does not force this library to switch to TypeScript, rather, the enum constants themselves are inlined upon compilation rather than using a static class.

I’m looking here to start a discussion about how to implement such a feature in the typings, and whether any of these solutions I’ve thought of so far are any good.

Issue Analytics

  • State:closed
  • Created 5 years ago
  • Reactions:7
  • Comments:16 (2 by maintainers)

github_iconTop GitHub Comments

4reactions
shellscapecommented, May 6, 2020

I’ve taken a different route to this and written a TypeScript transform plugin that exports a superstruct<T> method. The plugin itself will transform that method and interface type and automatically generate a superstruct schema from the interface. So it pretty much takes all of the worry away from type checking the schemas.

Will be released in the coming weeks.

3reactions
thesunnycommented, Mar 23, 2020

I was looking for / building the same thing and since I like @ianstormtaylor work, decided to take a crack at adding types to superstruct. This won’t handle the custom types as of yet (though I think with a bit of work it’s possible), but for all the basic types, I think this works. Good for typing API calls with incoming unknown types for example:

import { struct } from "superstruct"

export type Prettify<T> = T extends Promise<infer V>
  ? Promise<V>
  : T extends infer U
  ? { [K in keyof U]: Prettify<U[K]> }
  : never

type SchemaValue =
  | "string"
  | "string?"
  | "number"
  | "number?"
  | "boolean"
  | "boolean?"
  | ["string" | "number" | "boolean" | SchemaObject]
  | SchemaObject

type SchemaObject = {
  [key: string]: SchemaValue
}

type TypeFromSchemaValue<S extends SchemaValue> = S extends "string"
  ? string
  : S extends "string?"
  ? string | undefined
  : S extends "number"
  ? number
  : S extends "number?"
  ? number | undefined
  : S extends "boolean?"
  ? boolean | undefined
  : S extends "boolean"
  ? boolean
  : S extends SchemaObject
  ? TypeFromSchema<S>
  : S extends [infer T]
  ? T extends SchemaValue
    ? T[]
    : never
  : never

type TypeFromSchema<S extends SchemaObject> = {
  [K in keyof S]: TypeFromSchemaValue<S[K]>
}

export function typedStruct<S extends SchemaObject>(schema: S) {
  const validate = struct(schema)
  return (value: unknown): Prettify<TypeFromSchema<S>> => {
    try {
      validate(value)
      return value as Prettify<TypeFromSchema<S>>
    } catch (e) {
      e.message = `${e.message} for schema ${JSON.stringify(
        schema
      )} and value ${JSON.stringify(value)}`
      throw e
    }
  }
}

And here’s a simple jest unit test that passes:

import { typedStruct } from "."

describe("guard-schema", () => {
  it("should guard a user (superstruct example)", async () => {
    const User = typedStruct({
      id: "number",
      title: "string",
      is_published: "boolean?",
      tags: ["string"],
      author: {
        id: "number",
      },
    })
    const user = User({
      id: 123,
      title: "John",
      tags: ["dude", "cool"],
      author: { id: 234 },
    })
    expect(user).toEqual({
      id: 123,
      title: "John",
      tags: ["dude", "cool"],
      author: { id: 234 },
    })
  })

  it("should guard a user more complete", async () => {
    const User = typedStruct({
      id: "number",
      title: "string",
      is_published: "boolean?",
      tags: ["string"],
      friend_ids: ["number"],
      booleans: ["boolean"],
      author: {
        id: "number",
      },
      chickens: [{ name: "string", happy: "boolean" }],
    })
    const user = User({
      id: 123,
      title: "John",
      tags: ["dude", "cool"],
      friend_ids: [1, 2, 3],
      booleans: [true, false],
      author: { id: 234 },
      chickens: [
        { name: "plucky", happy: true },
        { name: "cross-the-road", happy: false },
      ],
    })
  })
})

And to show that it works, here’s the typing information from VSCode:

image

Maybe I’ll create an NPM package for this…

Read more comments on GitHub >

github_iconTop Results From Across the Web

TypeScript compile-time interface validation with tagged ...
It's a very powerful programming tool that helps us detect bugs before they can even run, by showing us compile errors. Using interfaces...
Read more >
Methods for TypeScript runtime type checking
Explore five methods of performing TypeScript type checks at runtime in this post and learn each of their advantages and disadvantages.
Read more >
Runtime type checking in TypeScript - learning-notes
TypeScript only performs static type checking at compile time! The generated JavaScript, which is what actually runs when you run your code, ...
Read more >
TypeScript Compile-time Operators by Gregory Pabian
In this very article, I would like to discuss the most relevant compile-time operators, based on my experience as a software developer. These ......
Read more >
Runtime type checking for TypeScript applications
Well, TypeScript only performs static type checking at compile time. The generated JavaScript, which is what actually runs when you run your ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found