@slonik/typegen
TypeScript icon, indicating that this package has built-in type declarations

0.15.0 • Public • Published

@slonik/typegen

Node CI codecov

A library that uses slonik to generate typescript interfaces based on your sql queries.

The idea

This library gives you the type-safety of an ORM (in some cases, even more), while maintaining the flexibility of sql. Read @gajus's excellent blog post on why it's a good idea to use sql rather than ORMs or query-builders: Stop using Knex.js.

It will make sure that return values from all your SQL queries have strict, accurate TypeScript interfaces.

It works by scanning your source code, so you don't have to spend any time manually syncing interfaces. Write queries using the sql tag as normal, then run the CLI to apply strong types to them automatically. The compiler will then tell you if you got something wrong.

This method avoids the inner-platform effect that tends to come with ORMs. You can rename columns, call functions, use sub-select statements, do any kinds of join you want, and the types generated will be based on the query, not the table, so you won't be limited by ORM feature-sets.

Select statements, joins, and updates/inserts/deletes using returning are all supported - any sql query that returns a tabular value will have an interface generated for the row type. The interface will be automatically applied to the appropriate query result.

Contents

Installation

npm install @slonik/typegen --save-dev

Usage

npx slonik-typegen generate

The above command will generate types using sensible default values. It will look for code in a src directory, and create interfaces for all sql-tagged queries it finds - e.g.

For a table defined with:

create table test_table(foo int not null, bar text);

comment on column test_table.bar is 'Look, ma! A comment from postgres!'

Source code before:

import {sql, createPool} from 'slonik'

export default async () => {
  const pool = createPool('...connection string...')

  const results = await pool.query(sql`select foo, bar from test_table`)

  results.rows.forEach(r => {
    console.log(r.foo)
    console.log(r.bar)
  })
}

Source code after:

import {sql, createPool} from 'slonik'

export default async () => {
  const pool = createPool('...connection string...')

  const results = await pool.query(sql<queries.TestTable>`select foo, bar from test_table`)

  results.rows.forEach(r => {
    console.log(r.foo) // foo has type 'number'
    console.log(r.bar) // bar has type 'string | null'
  })
}

export declare namespace queries {
  // Generated by @slonik/typegen

  /** - query: `select foo, bar from test_table` */
  export interface TestTable {
    /** column: `example_test.test_table.foo`, not null: `true`, regtype: `integer` */
    foo: number

    /**
     * Look, ma! A comment from postgres!
     *
     * column: `example_test.test_table.bar`, regtype: `text`
     */
    bar: string | null
  }
}

Configuration

The CLI can run with zero config, but there will usually be customisations needed depending on your project's setup.
By default, the CLI will look for typegen.config.js file in the working directory, exporting an object containing the properties below.

Some options are only available via CLI, some are only available in the config.
CLI arguments will always have precedence over config options.

Option CLI Argument             Type Default Description
rootDir --root-dir string 'src' Source root that the tool will search for files in.
include --include string[] ['**/*.{ts,sql}'] Glob patterns for files to include in processing. Repeatable in CLI.
exclude --exclude string[] ['**/node_modules/**'] Glob patterns for files to exclude from processing. Repeatable in CLI.
since --since string | undefined undefined Limit matched files to those which have been changed since the given git ref. Use "HEAD" for files changed since the last commit, "main" for files changed in a branch, etc.
connectionURI --connection-uri string 'postgresql://postgres:postgres@localhost:5432/postgres' URI for connecting to psql. Note that if you are using psql inside docker, you should make sure that the container and host port match, since this will be used both by psql and slonik to connect to the database.
psqlCommand --psql string 'psql' The CLI command for running the official postgres psql CLI client.
Note that right now this can't contain single quotes. This should also be configured to talk to the same database as the pool variable (and it should be a development database - don't run this tool in production!). If you are using docker compose, you can use a command like docker-compose exec -T postgres psql
defaultType --default-type string 'unknown' TypeScript type when no mapping is found. This should usually be unknown (or any if you like to live dangerously).
poolConfig PoolConfig | undefined
(see below)
undefined Slonik database pool configuration. Will be used to create a pool which issues queries to the database as the tool is running, and will have its type parsers inspected to ensure the generated types are correct. It's important to pass in a pool confguration which is the same as the one used in your application.
logger Logger
(see below)
console Logger object with debug, info, warn and error methods. Defaults to console.
writeTypes
(experimental)
WriteTypes
(see below)
typegen.defaultWriteTypes Control how files are written to disk. See the Advanced Configuration section.
--config string 'typegen.config.js' Path to configuration file.
--migrate '<=0.8.0' disabled Before generating types, attempt to migrate a codebase which has used a prior version of this tool.
--watch CLI argument disabled Run in watch mode.
--lazy CLI argument disabled Skip initial processing of input files. Only useful with '--watch'.
--skip-check-clean CLI argument disabled If enabled, the tool will not check the git status to ensure changes are checked in.

Documentation for CLI arguments can be printed to your terminal with npx slonik-typegen generate --help.

Complex config types

type Logger = Record<'error' | 'warn' | 'info' | 'debug', (msg: unknown) => void>;
type WriteTypes = (queries: AnalysedQuery[]) => Promise<void>;
type PoolConfig = slonik.ClientConfigurationInput; // imported from slonik lib

Testing psqlCommand

You can check if your psql is working, and that your postgres version supports \gdesc with your connection string using this shell command:

echo 'select 123 as abc \gdesc' \| psql "postgresql://postgres:postgres@localhost:5432/postgres" -f -

There are some more configuration options documented in code, but these should be considered experimental, and might change without warning. You can try them out as documented below, but please start a discussion on this library's project page with some info about your use case so the API can be stabilised in a sensible way.

Example config

Here's a valid example config file.

const yourAppDB = require('./lib/db')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  rootDir: 'source', // maybe you don't like using `src`
  include: ['{queries/**.ts,sql/**.sql}'],
  exclude: ['legacy-queries/**.sql'],
  connectionURI: 'postgresql://postgres:postgres@localhost:5432/postgres',
  poolConfig: yourAppDB.getPool().configuration,
}

Note that the /** @type {import('@slonik/typegen').Options} */ comment is optional, but will ensure your IDE gives you type hints.

Advanced Configuration

The writeTypes option allows you to tweak what's written to disk. Note that the usage style isn't finalised and might change in future. If you use it, please create a discussion about it in https://github.com/mmkal/slonik-tools/discussions so your use-case doesn't get taken away unexpectedly.

Controlling write destination

By default, interfaces for SQL queries are added to a module at the end of the typescript file they're found in. You can tell the CLI to write the interfaces to a separate file instead using writeTypes:

const path = require('path')
const typegen = require('@slonik/typegen')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: typegen.defaultWriteTypes({
    queriesPathFromTS: filepath => path.join(path.dirname(filepath), '__sql__', path.basename(filepath)),
  }),
}

The interfaces will be written to a separate file under a __sql__ folder next to the source, and will be imported via import * as queries from './__sql__/file-name'.

Modifying types

You can modify the types generated before they are written to disk by defining a custom writeTypes implementation.

For example, you can create branded types (see what this outputs in tests):

const typegen = require('@slonik/typegen')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: queries => {
    queries.forEach(query => {
      query.fields.forEach(field => {
        // add a `_brand` to all string id fields:
        if (field.typescript === 'string' && field.column && field.column.name === '.id') {
          field.typescript = `(${field.typescript} & { _brand: ${JSON.stringify(field.column)} })`
        }
      })
    })

    return typegen.defaultWriteTypes()(queries)
  }
}

Or you could mark all fields as non-null (but probably shouldn't!):

const typegen = require('@slonik/typegen')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: queries => {
    queries.forEach(query => {
      query.fields.forEach(field => {
        field.nullability = 'assumed_not_null'
      })
    })

    return typegen.defaults.defaultWriteTypes()(queries)
  }
}

Or you could be more granular. If, for example, there's a particular file with a lot of nullable types that you can't (yet) add full strict typing to:

const typegen = require('@slonik/typegen')
const path = require('path')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: queries => {
    queries.forEach(query => {
      const filesWithLegacyNullableFields = [
        path.resolve(__dirname, 'path/to/file1.ts'),
        path.resolve(__dirname, 'path/to/file2.ts'),
      ]
      if (filesWithLegacyNullableFields.includes(query.file)) {
        query.fields.forEach(field => {
          if (field.nullability === 'unknown') {
            field.nullability = 'assumed_not_null'
          }
        })
      }
    })

    return typegen.defaults.defaultWriteTypes()(queries)
  }
}

Or you could use a custom type for json fields:

const typegen = require('@slonik/typegen')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: queries => {
    queries.forEach(query => {
      query.fields.forEach(field => {
        if (field.regtype === 'json' || field.regtype === 'jsonb') {
          field.typescript = `import('@your-project/custom-types').YourCustomType`
          // For more customisation, you could look up which type to use based on `field.column`.
        }
      })
    })

    return typegen.defaults.defaultWriteTypes()(queries)
  }
}

Modifying source files

You can also use writeTypes to define a hook that runs before writing to disk:

const typegen = require('@slonik/typegen')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: typegen.defaultWriteTypes({
    writeFile: async (filepath, content) => {
      content = content
        .replace(/declare module queries/g, 'declare module some_other_naming_convention')
        .replace(/queries\./g, 'some_other_naming_convention.')
      await typegen.defaults.defaultWriteFile(filepath, content)
    },
  })
}

Or you could override the default formatter (which uses prettier, if found):

const typegen = require('@slonik/typegen')
const yourCustomLinter = require('@your-project/custom-linter')
const fs = require('fs')
const path = require('path')

/** @type {import('@slonik/typegen').Options} */
module.exports.default = {
  writeTypes: typegen.defaults.defaultWriteTypes({
    writeFile: async (filepath, content) => {
      content = await yourCustomLinter.fix(filepath, content)
      await fs.promises.mkdir(path.dirname(filepath), {recursive: true}) // since you're not using the built-in `writeFile` you should explicitly call mkdir with {recursive: true}
      await fs.promises.writeFile(filepath, content)
    },
  })
}

Enhancing Return Types

Typegen is designed to output types only to the degree it's certain they are correct.

Let's say in a complex query it can determine that a specific column will return a string, but isn't sure if it is also nullable, it will extract the type as { column: string | null }, just to be on the safe side. When it encounters columns where it is unable to even determine the basic type, i.e. json columns, it will return 🤷 (Ok, actually the typescript equivalent, which is unknown).

In these cases you likely know more about the actual return type than typegen and you might feel the urge to overwrite the types.
Yet you shouldn't touch generated code, as your changes will be removed again on the next run.

Instead what you should do is add (one or more) intersection types to the sql literal, specifying the columns where you want to help typegen out by increasing specificity. The resulting type will be a combination of the extracted types and your enhancements.
Check out the typescript docs on intersection types to learn more.

Imagine this is your code after running typegen.

sql<queries.ExtractedResult>`select string_col, json_col from table`

export declare namespace queries {
  // Generated by @slonik/typegen

  /** - query: `select string_col, json_col from table` */
  export interface TestTable {
    /** column: `example_test.table.string_col`, regtype: `character_varying` */
    string_col: string | null,
    /** column: `example_test.table.json_col`, regtype: `jsonb` */
    json_col: unkown
  }
}

You can enhance the return type like this:

sql<queries.ExtractedResult & { json_col: string[] }>`[query]`

- or, if you prefer -

interface EnhancedResult {
  json_col: string[]
}
sql<queries.ExtractedResult & EnhancedResult>`[query]`

Either way the resulting type will be this:

type ResultingType = {
  string_col: string | null,
  json_col: string[]
}

On subsequent runs typegen will only update the first intersection type and leave all following intersections untouched.

This also means you can make the column string_col non-nullable by intersecting it with { string_col: string }.

Note that you can't completely change a property type (say from string to number) this way.
This is by design, because if you could, a change in the underlying table might cause typegen to detect a new type, which would be ignored, had you overwritten it. This would cause type changes to go unnoticed and we can't have that.
With intersections, the resulting property will be of type never, when an underlying column type changes. This will alert you to the change, so you can update your manual enhancements.

Ignoring Queries

For file-based ignores, you can use the exclude option to set a pattern or specific file(s) to be ignored via the config file or using the CLI option.

Typegen also automatically ignores all queries with zero chance of returning a result (i.e. sql fragments).

If you want to exclude a specific query from processing, you can add a --typegen-ignore or /* typegen-ignore */ comment anywhere in the query.

Examples

The tests and corresponding fixtures are a good starting point to see what the code-generator will do.

Migration from v0.8.0

Version 0.8.0 and below of this library used a different style of code-generation. It had several drawbacks - it was a runtime dependency, and required queries to be actually run before types could be inferred. It also created a global repository of types, meaning two queries in separate locations which shared a name could clash with each other. It also required changing the way your code was written.

Conceptually, this library now does more work so you don't have to worry about it so much. Just write slonik code/queries as normal, and then run the CLI to add types to them. If you add a new column to any query, run it again to update the interfaces.

If you previously used the old version of the tool, you can run it once with the --migrate v0.8.0 CLI argument to automatically attempt to codemod your project. Note that this will, by default, check that your git status is clean before running since it modifies code in place. The codemod isn't advanced enough to find all usages of the old API, so have a look through what it does after running it to make sure the changes look OK. If they aren't, reset the git changes and either apply them manually and/or pass in a different include value to avoid files that were incorrectly modified.

SQL files

The tool will also search for .sql files, and generate some typescript helpers for running the queries contained in them. Any parameters ($1, $2 etc.) will also be strongly typed, and become required inputs for running the query. See the SQL file fixtures for some examples, and the generated SQL usage test to see how it can be used.

Usage with @typescript-eslint

The default ruleset for @typescript-eslint/eslint-plugin prevents usage of typescript namespaces. To avoid lint errors for inline type declarations (which are perfectly valid!), add this to your eslint config:

"@typescript-eslint/no-namespace": ["warn", {"allowDeclarations": true}],

Limitations

Some dynamically-generated queries will not receive a type. One example is any query where the template express parameters are identifiers rather than values, e.g.

import {sql} from 'slonik'

const tableName = Math.random() < 0.5 ? 'foo' : 'bar'

export default sql`select * from ${sql.identifier([tableName])}`

In the above example, no type can be inferred because it's impossible to know whether the query will return values from table foo or bar.


Queries with multiple statements will result in an error:

import {sql} from 'slonik'

sql`
  update table set col=1 where id=1 returning 1;
  update table set col=2 where id=2 returning 2;
`

The return type is not clearly assigned here. Every literal should only contain one query statement.


Queries using the pg_temp schema will usually not be typeable since the schema is ephemeral and only can be queried within a single session that psql doesn't have access to.

import {sql} from 'slonik'

sql`select * from pg_temp.my_temp_table`

Invalid SQL syntax will also be left untouched (they will result in an error being logged when running the CLI):

import {sql} from 'slonik'

sql`this is not even valid SQL!`

If you see errors being logged for SQL that you think is valid, feel free to raise an issue.
In the meantime, you can use of of the ignore options to skip processing the concerned queries.


Custom interceptors. Some interceptors, such as slonik-interceptor-field-name-transformation change the runtime shape of query results. You could try to match its behaviour with a custom writeTypes implementation, but it's recommended to just not using the interceptor in the first place. All it does is transform from snake-case to camel-case.


Finally, for some complex queries, static parsing might fail, making it not possible to determine statically if a column is nullable. If this happens, it will still receive a valid type, but the type will be string | null rather than string.

If you find such a case, please raise an issue to see if it's possible to handle - under the hood this library uses pgsql-ast-parser and you might have found an edge case which that library doesn't handle yet.

How it works

When you run slonik-typegen generate, the tool will scan your source files, and traverse their ASTs using the TypeScript compiler API. Note that typescript is a peer dependency for this reason.

On finding a sql query, it will issue a psql command using the flag \gdesc, which responds with a table of the columns and their corresponding types contained in the query. The query itself is never actually run.

The postgres type is then converted into typescript using an in-built mapping. Any slonik typeParsers configured (see slonik docs for more info) are inspected to infer the type of the value that will be returned by the query.

To determine whether query columns are nullable, the query is parsed using pgsql-ast-parser. Some more queries are sent to postgres to figure out whether query column can be null - in general, postgres is only able to guarantee if a query column is null if it comes directly from a table which declares that column non-null too.

Recommendations

  1. Check in the types to source control. They're generated code, but it makes it much easier to track what was happened when a query was update, and see those changes over time.
  2. After running CI, it's worth making sure that there are no working copy changes. For git, you can use git diff --exit-code:
npx slonik-typegen generate
git diff --exit-code

Readme

Keywords

none

Package Sidebar

Install

npm i @slonik/typegen

Weekly Downloads

494

Version

0.15.0

License

ISC

Unpacked Size

238 kB

Total Files

108

Last publish

Collaborators

  • gajus
  • mmkale