Repository: sql-formatter-org/sql-formatter
Pull request #493 (2022-10-05 12:50:20)

Replace extending of Formatter with DialectOptions object
Implemented what I proposed in #484.

Dialects are now defined by config object instead of subclassing `Formatter`.

**Note:** This PR only changes the public API with regards to the experimental `language: string | Formatter` field, which has now been replaced with `language: string | DialectOptions`.


Patch:
diff --git a/docs/language.md b/docs/language.md
index 8e9c7327..2f17d372 100644
--- a/docs/language.md
+++ b/docs/language.md
@@ -20,34 +20,35 @@ Specifies the SQL dialect to use.
 - `"sqlite"` - [SQLite][sqlite]
 - `"transactsql"` or `"tsql"` - [SQL Server Transact-SQL][tsql]
 - `"trino"` - [Trino][] / [Presto][]
-- custom formatter class (see below)
+- custom SQL dialect configuration object (see below)
 
 The default `"sql"` dialect is meant for cases where you don't know which dialect of SQL you're about to format.
 It's not an auto-detection, it just supports a subset of features common enough in many SQL implementations.
 This might or might not work for your specific dialect.
 Better to always pick something more specific if possible.
 
-### Custom formatter class (experimental)
+### Custom dialect configuration (experimental)
 
-The language parameter can also be used to specify a custom formatter implementation:
+The language parameter can also be used to specify a custom SQL dialect configuration:
 
 ```ts
-import { format, Formatter, Tokenizer } from 'sql-formatter';
+import { format, DialectOptions } from 'sql-formatter';
 
-class MyFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      // See source code for examples of tokenizer config options
-      // For example: src/languages/sqlite/sqlite.formatter.ts
-    });
-  }
-}
+const myDialect: DialectOptions {
+  tokenizerOptions: {
+    // See source code for examples of tokenizer config options
+    // For example: src/languages/sqlite/sqlite.formatter.ts
+  },
+  formatOptions: {
+    // ...
+  },
+};
 
-const result = format('SELECT * FROM tbl', { language: MyFormatter });
+const result = format('SELECT * FROM tbl', { language: myDialect });
 ```
 
 **NB!** This functionality is experimental and there are no stability guarantees for this API.
-The API of Formatter and Tokenizer classes can (and likely will) change in non-major releases.
+The DialectOptions interface can (and likely will) change in non-major releases.
 You likely only want to use this if your other alternative is to fork SQL Formatter.
 
 [standard sql]: https://en.wikipedia.org/wiki/SQL:2011
diff --git a/src/FormatOptions.ts b/src/FormatOptions.ts
index dec52428..c785b009 100644
--- a/src/FormatOptions.ts
+++ b/src/FormatOptions.ts
@@ -1,7 +1,5 @@
 // import only type to avoid ESLint no-cycle rule producing an error
-import type { SqlLanguage } from './sqlFormatter.js';
 import { ParamItems } from './formatter/Params.js';
-import Formatter from './formatter/Formatter.js';
 import { ParamTypes } from './lexer/TokenizerOptions.js';
 
 export type IndentStyle = 'standard' | 'tabularLeft' | 'tabularRight';
@@ -13,7 +11,6 @@ export type CommaPosition = 'before' | 'after' | 'tabular';
 export type LogicalOperatorNewline = 'before' | 'after';
 
 export interface FormatOptions {
-  language: SqlLanguage | typeof Formatter;
   tabWidth: number;
   useTabs: boolean;
   keywordCase: KeywordCase;
diff --git a/src/dialect.ts b/src/dialect.ts
new file mode 100644
index 00000000..ddcafa24
--- /dev/null
+++ b/src/dialect.ts
@@ -0,0 +1,44 @@
+import {
+  DialectFormatOptions,
+  ProcessedDialectFormatOptions,
+} from './formatter/ExpressionFormatter.js';
+import Tokenizer from './lexer/Tokenizer.js';
+import { TokenizerOptions } from './lexer/TokenizerOptions.js';
+
+export interface DialectOptions {
+  tokenizerOptions: TokenizerOptions;
+  formatOptions: DialectFormatOptions;
+}
+
+export interface Dialect {
+  tokenizer: Tokenizer;
+  formatOptions: ProcessedDialectFormatOptions;
+}
+
+const cache = new Map<DialectOptions, Dialect>();
+
+/**
+ * Factory function for building Dialect objects.
+ * When called repeatedly with same options object returns the cached Dialect,
+ * to avoid the cost of creating it again.
+ */
+export const createDialect = (options: DialectOptions): Dialect => {
+  let dialect = cache.get(options);
+  if (!dialect) {
+    dialect = dialectFromOptions(options);
+    cache.set(options, dialect);
+  }
+  return dialect;
+};
+
+const dialectFromOptions = (dialectOptions: DialectOptions): Dialect => ({
+  tokenizer: new Tokenizer(dialectOptions.tokenizerOptions),
+  formatOptions: processDialectFormatOptions(dialectOptions.formatOptions),
+});
+
+const processDialectFormatOptions = (
+  options: DialectFormatOptions
+): ProcessedDialectFormatOptions => ({
+  alwaysDenseOperators: options.alwaysDenseOperators || [],
+  onelineClauses: Object.fromEntries(options.onelineClauses.map(name => [name, true])),
+});
diff --git a/src/formatter/Formatter.ts b/src/formatter/Formatter.ts
index fd319d11..26d5c8da 100644
--- a/src/formatter/Formatter.ts
+++ b/src/formatter/Formatter.ts
@@ -1,62 +1,29 @@
 import { FormatOptions } from '../FormatOptions.js';
 import { indentString } from './config.js';
 import Params from './Params.js';
-import Tokenizer from '../lexer/Tokenizer.js';
 
 import { createParser } from '../parser/createParser.js';
 import { StatementNode } from '../parser/ast.js';
-import { cacheInClassField } from '../utils.js';
+import { Dialect } from '../dialect.js';
 
 import formatCommaPositions from './formatCommaPositions.js';
 import formatAliasPositions from './formatAliasPositions.js';
-import ExpressionFormatter, {
-  DialectFormatOptions,
-  ProcessedDialectFormatOptions,
-} from './ExpressionFormatter.js';
+import ExpressionFormatter from './ExpressionFormatter.js';
 import Layout, { WS } from './Layout.js';
 import Indentation from './Indentation.js';
 
 /** Main formatter class that produces a final output string from list of tokens */
 export default class Formatter {
+  private dialect: Dialect;
   private cfg: FormatOptions;
   private params: Params;
 
-  constructor(cfg: FormatOptions) {
+  constructor(dialect: Dialect, cfg: FormatOptions) {
+    this.dialect = dialect;
     this.cfg = cfg;
     this.params = new Params(this.cfg.params);
   }
 
-  /**
-   * SQL Tokenizer for this formatter, provided by subclasses.
-   */
-  protected tokenizer(): Tokenizer {
-    throw new Error('tokenizer() not implemented by subclass');
-  }
-
-  // Cache the tokenizer for each class (each SQL dialect)
-  // So we wouldn't need to recreate the tokenizer, which is kinda expensive,
-  // for each call to format() function.
-  private cachedTokenizer(): Tokenizer {
-    return cacheInClassField(this.constructor, 'cachedTokenizer', () => this.tokenizer());
-  }
-
-  /**
-   * Dialect-specific formatting configuration, provided by subclass.
-   */
-  protected formatOptions(): DialectFormatOptions {
-    throw new Error('formatOptions() not implemented by sybclass');
-  }
-
-  private cachedFormatOptions(): ProcessedDialectFormatOptions {
-    return cacheInClassField(this.constructor, 'cachedFormatOptions', () => {
-      const opts = this.formatOptions();
-      return {
-        alwaysDenseOperators: opts.alwaysDenseOperators || [],
-        onelineClauses: Object.fromEntries(opts.onelineClauses.map(name => [name, true])),
-      };
-    });
-  }
-
   /**
    * Formats an SQL query.
    * @param {string} query - The SQL query string to be formatted
@@ -71,7 +38,7 @@ export default class Formatter {
   }
 
   private parse(query: string): StatementNode[] {
-    return createParser(this.cachedTokenizer()).parse(query, this.cfg.paramTypes || {});
+    return createParser(this.dialect.tokenizer).parse(query, this.cfg.paramTypes || {});
   }
 
   private formatAst(statements: StatementNode[]): string {
@@ -83,7 +50,7 @@ export default class Formatter {
   private formatStatement(statement: StatementNode): string {
     const layout = new ExpressionFormatter({
       cfg: this.cfg,
-      dialectCfg: this.cachedFormatOptions(),
+      dialectCfg: this.dialect.formatOptions,
       params: this.params,
       layout: new Layout(new Indentation(indentString(this.cfg))),
     }).format(statement.children);
diff --git a/src/index.ts b/src/index.ts
index 9a9a64b3..ed358225 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,5 @@ export type {
   LogicalOperatorNewline,
   FormatOptions,
 } from './FormatOptions.js';
-export { default as Formatter } from './formatter/Formatter.js';
-export { default as Tokenizer } from './lexer/Tokenizer.js';
+export type { DialectOptions } from './dialect.js';
 export { expandPhrases } from './expandPhrases.js';
diff --git a/src/languages/bigquery/bigquery.formatter.ts b/src/languages/bigquery/bigquery.formatter.ts
index a735b987..ab0c8ac2 100644
--- a/src/languages/bigquery/bigquery.formatter.ts
+++ b/src/languages/bigquery/bigquery.formatter.ts
@@ -1,6 +1,4 @@
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
+import { DialectOptions } from '../../dialect.js';
 import { EOF_TOKEN, isToken, TokenType, Token } from '../../lexer/token.js';
 import { expandPhrases } from '../../expandPhrases.js';
 import { keywords } from './bigquery.keywords.js';
@@ -155,42 +153,37 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://cloud.google.com/bigquery/docs/reference/#standard-sql-reference
-export default class BigQueryFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      extraParens: ['[]'],
-      stringTypes: [
-        // The triple-quoted strings are listed first, so they get matched first.
-        // Otherwise the first two quotes of """ will get matched as an empty "" string.
-        { quote: '""".."""', prefixes: ['R', 'B', 'RB', 'BR'] },
-        { quote: "'''..'''", prefixes: ['R', 'B', 'RB', 'BR'] },
-        '""-bs',
-        "''-bs",
-        { quote: '""-raw', prefixes: ['R', 'B', 'RB', 'BR'], requirePrefix: true },
-        { quote: "''-raw", prefixes: ['R', 'B', 'RB', 'BR'], requirePrefix: true },
-      ],
-      identTypes: ['``'],
-      identChars: { dashes: true },
-      paramTypes: { positional: true, named: ['@'], quoted: ['@'] },
-      lineCommentTypes: ['--', '#'],
-      operators: ['&', '|', '^', '~', '>>', '<<', '||'],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const bigquery: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    extraParens: ['[]'],
+    stringTypes: [
+      // The triple-quoted strings are listed first, so they get matched first.
+      // Otherwise the first two quotes of """ will get matched as an empty "" string.
+      { quote: '""".."""', prefixes: ['R', 'B', 'RB', 'BR'] },
+      { quote: "'''..'''", prefixes: ['R', 'B', 'RB', 'BR'] },
+      '""-bs',
+      "''-bs",
+      { quote: '""-raw', prefixes: ['R', 'B', 'RB', 'BR'], requirePrefix: true },
+      { quote: "''-raw", prefixes: ['R', 'B', 'RB', 'BR'], requirePrefix: true },
+    ],
+    identTypes: ['``'],
+    identChars: { dashes: true },
+    paramTypes: { positional: true, named: ['@'], quoted: ['@'] },
+    lineCommentTypes: ['--', '#'],
+    operators: ['&', '|', '^', '~', '>>', '<<', '||'],
+    postProcess,
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]): Token[] {
   return detectArraySubscripts(combineParameterizedTypes(tokens));
diff --git a/src/languages/db2/db2.formatter.ts b/src/languages/db2/db2.formatter.ts
index 7fdef5c6..8afd15b2 100644
--- a/src/languages/db2/db2.formatter.ts
+++ b/src/languages/db2/db2.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './db2.functions.js';
 import { keywords } from './db2.keywords.js';
 
@@ -178,30 +176,25 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://www.ibm.com/support/knowledgecenter/en/ssw_ibm_i_72/db2/rbafzintro.htm
-export default class Db2Formatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: [
-        { quote: "''-qq", prefixes: ['G', 'N', 'U&'] },
-        { quote: "''-raw", prefixes: ['X', 'BX', 'GX', 'UX'], requirePrefix: true },
-      ],
-      identTypes: [`""-qq`],
-      paramTypes: { positional: true, named: [':'] },
-      paramChars: { first: '@#$', rest: '@#$' },
-      operators: ['**', '¬=', '¬>', '¬<', '!>', '!<', '||'],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const db2: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: [
+      { quote: "''-qq", prefixes: ['G', 'N', 'U&'] },
+      { quote: "''-raw", prefixes: ['X', 'BX', 'GX', 'UX'], requirePrefix: true },
+    ],
+    identTypes: [`""-qq`],
+    paramTypes: { positional: true, named: [':'] },
+    paramChars: { first: '@#$', rest: '@#$' },
+    operators: ['**', '¬=', '¬>', '¬<', '!>', '!<', '||'],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/languages/hive/hive.formatter.ts b/src/languages/hive/hive.formatter.ts
index 997c829a..a7cb4192 100644
--- a/src/languages/hive/hive.formatter.ts
+++ b/src/languages/hive/hive.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './hive.functions.js';
 import { keywords } from './hive.keywords.js';
 
@@ -84,27 +82,22 @@ const reservedJoins = expandPhrases([
 const reservedPhrases = expandPhrases(['{ROWS | RANGE} BETWEEN']);
 
 // https://cwiki.apache.org/confluence/display/Hive/LanguageManual
-export default class HiveFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      extraParens: ['[]'],
-      stringTypes: ['""-bs', "''-bs"],
-      identTypes: ['``'],
-      variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
-      operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||'],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const hive: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    extraParens: ['[]'],
+    stringTypes: ['""-bs', "''-bs"],
+    identTypes: ['``'],
+    variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
+    operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||'],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/languages/mariadb/mariadb.formatter.ts b/src/languages/mariadb/mariadb.formatter.ts
index 89ceac0d..687e86d3 100644
--- a/src/languages/mariadb/mariadb.formatter.ts
+++ b/src/languages/mariadb/mariadb.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { EOF_TOKEN, isToken, Token, TokenType } from '../../lexer/token.js';
 import { keywords } from './mariadb.keywords.js';
 import { functions } from './mariadb.functions.js';
@@ -266,44 +264,39 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // For reference: https://mariadb.com/kb/en/sql-statements-structure/
-export default class MariaDbFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      supportsXor: true,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      // TODO: support _ char set prefixes such as _utf8, _latin1, _binary, _utf8mb4, etc.
-      stringTypes: [
-        '""-qq-bs',
-        "''-qq-bs",
-        { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
-      ],
-      identTypes: ['``'],
-      identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
-      variableTypes: [
-        { regex: '@@?[A-Za-z0-9_.$]+' },
-        { quote: '""-qq-bs', prefixes: ['@'], requirePrefix: true },
-        { quote: "''-qq-bs", prefixes: ['@'], requirePrefix: true },
-        { quote: '``', prefixes: ['@'], requirePrefix: true },
-      ],
-      paramTypes: { positional: true },
-      lineCommentTypes: ['--', '#'],
-      operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||', '!'],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const mariadb: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    supportsXor: true,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    // TODO: support _ char set prefixes such as _utf8, _latin1, _binary, _utf8mb4, etc.
+    stringTypes: [
+      '""-qq-bs',
+      "''-qq-bs",
+      { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
+    ],
+    identTypes: ['``'],
+    identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
+    variableTypes: [
+      { regex: '@@?[A-Za-z0-9_.$]+' },
+      { quote: '""-qq-bs', prefixes: ['@'], requirePrefix: true },
+      { quote: "''-qq-bs", prefixes: ['@'], requirePrefix: true },
+      { quote: '``', prefixes: ['@'], requirePrefix: true },
+    ],
+    paramTypes: { positional: true },
+    lineCommentTypes: ['--', '#'],
+    operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||', '!'],
+    postProcess,
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]) {
   return tokens.map((token, i) => {
diff --git a/src/languages/mysql/mysql.formatter.ts b/src/languages/mysql/mysql.formatter.ts
index a21e102c..d448dd0b 100644
--- a/src/languages/mysql/mysql.formatter.ts
+++ b/src/languages/mysql/mysql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { EOF_TOKEN, isToken, Token, TokenType } from '../../lexer/token.js';
 import { keywords } from './mysql.keywords.js';
 import { functions } from './mysql.functions.js';
@@ -233,44 +231,39 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://dev.mysql.com/doc/refman/8.0/en/
-export default class MySqlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      supportsXor: true,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      // TODO: support _ char set prefixes such as _utf8, _latin1, _binary, _utf8mb4, etc.
-      stringTypes: [
-        '""-qq-bs',
-        { quote: "''-qq-bs", prefixes: ['N'] },
-        { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
-      ],
-      identTypes: ['``'],
-      identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
-      variableTypes: [
-        { regex: '@@?[A-Za-z0-9_.$]+' },
-        { quote: '""-qq-bs', prefixes: ['@'], requirePrefix: true },
-        { quote: "''-qq-bs", prefixes: ['@'], requirePrefix: true },
-        { quote: '``', prefixes: ['@'], requirePrefix: true },
-      ],
-      paramTypes: { positional: true },
-      lineCommentTypes: ['--', '#'],
-      operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '->', '->>', '&&', '||', '!'],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const mysql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    supportsXor: true,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    // TODO: support _ char set prefixes such as _utf8, _latin1, _binary, _utf8mb4, etc.
+    stringTypes: [
+      '""-qq-bs',
+      { quote: "''-qq-bs", prefixes: ['N'] },
+      { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
+    ],
+    identTypes: ['``'],
+    identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
+    variableTypes: [
+      { regex: '@@?[A-Za-z0-9_.$]+' },
+      { quote: '""-qq-bs', prefixes: ['@'], requirePrefix: true },
+      { quote: "''-qq-bs", prefixes: ['@'], requirePrefix: true },
+      { quote: '``', prefixes: ['@'], requirePrefix: true },
+    ],
+    paramTypes: { positional: true },
+    lineCommentTypes: ['--', '#'],
+    operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '->', '->>', '&&', '||', '!'],
+    postProcess,
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]) {
   return tokens.map((token, i) => {
diff --git a/src/languages/n1ql/n1ql.formatter.ts b/src/languages/n1ql/n1ql.formatter.ts
index 7e7029ca..cde64047 100644
--- a/src/languages/n1ql/n1ql.formatter.ts
+++ b/src/languages/n1ql/n1ql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './n1ql.functions.js';
 import { keywords } from './n1ql.keywords.js';
 
@@ -85,32 +83,27 @@ const reservedJoins = expandPhrases(['JOIN', '{LEFT | RIGHT} [OUTER] JOIN', 'INN
 const reservedPhrases = expandPhrases(['{ROWS | RANGE | GROUPS} BETWEEN']);
 
 // For reference: http://docs.couchbase.com.s3-website-us-west-1.amazonaws.com/server/6.0/n1ql/n1ql-language-reference/index.html
-export default class N1qlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      supportsXor: true,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      // NOTE: single quotes are actually not supported in N1QL,
-      // but we support them anyway as all other SQL dialects do,
-      // which simplifies writing tests that are shared between all dialects.
-      stringTypes: ['""-bs', "''-bs"],
-      identTypes: ['``'],
-      extraParens: ['[]', '{}'],
-      paramTypes: { positional: true, numbered: ['$'], named: ['$'] },
-      lineCommentTypes: ['#', '--'],
-      operators: ['%', '==', ':', '||'],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const n1ql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    supportsXor: true,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    // NOTE: single quotes are actually not supported in N1QL,
+    // but we support them anyway as all other SQL dialects do,
+    // which simplifies writing tests that are shared between all dialects.
+    stringTypes: ['""-bs', "''-bs"],
+    identTypes: ['``'],
+    extraParens: ['[]', '{}'],
+    paramTypes: { positional: true, numbered: ['$'], named: ['$'] },
+    lineCommentTypes: ['#', '--'],
+    operators: ['%', '==', ':', '||'],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/languages/plsql/plsql.formatter.ts b/src/languages/plsql/plsql.formatter.ts
index 9879c2dd..bcb4258b 100644
--- a/src/languages/plsql/plsql.formatter.ts
+++ b/src/languages/plsql/plsql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { EOF_TOKEN, isReserved, isToken, Token, TokenType } from '../../lexer/token.js';
 import { keywords } from './plsql.keywords.js';
 import { functions } from './plsql.functions.js';
@@ -83,53 +81,48 @@ const reservedPhrases = expandPhrases([
   '{ROWS | RANGE} BETWEEN',
 ]);
 
-export default class PlSqlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      supportsXor: true,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: [
-        { quote: "''-qq", prefixes: ['N'] },
-        { quote: "q''", prefixes: ['N'] },
-      ],
-      // PL/SQL doesn't actually support escaping of quotes in identifiers,
-      // but for the sake of simpler testing we'll support this anyway
-      // as all other SQL dialects with "identifiers" do.
-      identTypes: [`""-qq`],
-      identChars: { rest: '$#' },
-      variableTypes: [{ regex: '&{1,2}[A-Za-z][A-Za-z0-9_$#]*' }],
-      paramTypes: { numbered: [':'], named: [':'] },
-      paramChars: {}, // Empty object used on purpose to not allow $ and # chars as specified in identChars
-      operators: [
-        '**',
-        ':=',
-        '%',
-        '~=',
-        '^=',
-        // '..', // Conflicts with float followed by dot (so "2..3" gets parsed as ["2.", ".", "3"])
-        '>>',
-        '<<',
-        '=>',
-        '@',
-        '||',
-      ],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      alwaysDenseOperators: ['@'],
-      onelineClauses,
-    };
-  }
-}
+export const plsql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    supportsXor: true,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: [
+      { quote: "''-qq", prefixes: ['N'] },
+      { quote: "q''", prefixes: ['N'] },
+    ],
+    // PL/SQL doesn't actually support escaping of quotes in identifiers,
+    // but for the sake of simpler testing we'll support this anyway
+    // as all other SQL dialects with "identifiers" do.
+    identTypes: [`""-qq`],
+    identChars: { rest: '$#' },
+    variableTypes: [{ regex: '&{1,2}[A-Za-z][A-Za-z0-9_$#]*' }],
+    paramTypes: { numbered: [':'], named: [':'] },
+    paramChars: {}, // Empty object used on purpose to not allow $ and # chars as specified in identChars
+    operators: [
+      '**',
+      ':=',
+      '%',
+      '~=',
+      '^=',
+      // '..', // Conflicts with float followed by dot (so "2..3" gets parsed as ["2.", ".", "3"])
+      '>>',
+      '<<',
+      '=>',
+      '@',
+      '||',
+    ],
+    postProcess,
+  },
+  formatOptions: {
+    alwaysDenseOperators: ['@'],
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]) {
   let previousReservedToken: Token = EOF_TOKEN;
diff --git a/src/languages/postgresql/postgresql.formatter.ts b/src/languages/postgresql/postgresql.formatter.ts
index 5ad88bd0..b057b38a 100644
--- a/src/languages/postgresql/postgresql.formatter.ts
+++ b/src/languages/postgresql/postgresql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Formatter from '../../formatter/Formatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './postgresql.functions.js';
 import { keywords } from './postgresql.keywords.js';
 
@@ -251,120 +249,115 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://www.postgresql.org/docs/14/index.html
-export default class PostgreSqlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      nestedBlockComments: true,
-      extraParens: ['[]'],
-      stringTypes: [
-        '$$',
-        { quote: "''-qq", prefixes: ['U&'] },
-        { quote: "''-bs", prefixes: ['E'], requirePrefix: true },
-        { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
-      ],
-      identTypes: [{ quote: '""-qq', prefixes: ['U&'] }],
-      identChars: { rest: '$' },
-      paramTypes: { numbered: ['$'] },
-      operators: [
-        // Arithmetic
-        '%',
-        '^',
-        '|/',
-        '||/',
-        '@',
-        // Assignment
-        ':=',
-        // Bitwise
-        '&',
-        '|',
-        '#',
-        '~',
-        '<<',
-        '>>',
-        // Byte comparison
-        '~>~',
-        '~<~',
-        '~>=~',
-        '~<=~',
-        // Geometric
-        '@-@',
-        '@@',
-        '##',
-        '<->',
-        '&&',
-        '&<',
-        '&>',
-        '<<|',
-        '&<|',
-        '|>>',
-        '|&>',
-        '<^',
-        '^>',
-        '?#',
-        '?-',
-        '?|',
-        '?-|',
-        '?||',
-        '@>',
-        '<@',
-        '~=',
-        // JSON
-        '?',
-        '@?',
-        '?&',
-        '->',
-        '->>',
-        '#>',
-        '#>>',
-        '#-',
-        // Named function params
-        '=>',
-        // Network address
-        '>>=',
-        '<<=',
-        // Pattern matching
-        '~~',
-        '~~*',
-        '!~~',
-        '!~~*',
-        // POSIX RegExp
-        '~',
-        '~*',
-        '!~',
-        '!~*',
-        // Range/multirange
-        '-|-',
-        // String concatenation
-        '||',
-        // Text search
-        '@@@',
-        '!!',
-        // Trigram/trigraph
-        '<%',
-        '%>',
-        '<<%',
-        '%>>',
-        '<<->',
-        '<->>',
-        '<<<->',
-        '<->>>',
-        // Type cast
-        '::',
-      ],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      alwaysDenseOperators: ['::'],
-      onelineClauses,
-    };
-  }
-}
+export const postgresql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    nestedBlockComments: true,
+    extraParens: ['[]'],
+    stringTypes: [
+      '$$',
+      { quote: "''-qq", prefixes: ['U&'] },
+      { quote: "''-bs", prefixes: ['E'], requirePrefix: true },
+      { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
+    ],
+    identTypes: [{ quote: '""-qq', prefixes: ['U&'] }],
+    identChars: { rest: '$' },
+    paramTypes: { numbered: ['$'] },
+    operators: [
+      // Arithmetic
+      '%',
+      '^',
+      '|/',
+      '||/',
+      '@',
+      // Assignment
+      ':=',
+      // Bitwise
+      '&',
+      '|',
+      '#',
+      '~',
+      '<<',
+      '>>',
+      // Byte comparison
+      '~>~',
+      '~<~',
+      '~>=~',
+      '~<=~',
+      // Geometric
+      '@-@',
+      '@@',
+      '##',
+      '<->',
+      '&&',
+      '&<',
+      '&>',
+      '<<|',
+      '&<|',
+      '|>>',
+      '|&>',
+      '<^',
+      '^>',
+      '?#',
+      '?-',
+      '?|',
+      '?-|',
+      '?||',
+      '@>',
+      '<@',
+      '~=',
+      // JSON
+      '?',
+      '@?',
+      '?&',
+      '->',
+      '->>',
+      '#>',
+      '#>>',
+      '#-',
+      // Named function params
+      '=>',
+      // Network address
+      '>>=',
+      '<<=',
+      // Pattern matching
+      '~~',
+      '~~*',
+      '!~~',
+      '!~~*',
+      // POSIX RegExp
+      '~',
+      '~*',
+      '!~',
+      '!~*',
+      // Range/multirange
+      '-|-',
+      // String concatenation
+      '||',
+      // Text search
+      '@@@',
+      '!!',
+      // Trigram/trigraph
+      '<%',
+      '%>',
+      '<<%',
+      '%>>',
+      '<<->',
+      '<->>',
+      '<<<->',
+      '<->>>',
+      // Type cast
+      '::',
+    ],
+  },
+  formatOptions: {
+    alwaysDenseOperators: ['::'],
+    onelineClauses,
+  },
+};
diff --git a/src/languages/redshift/redshift.formatter.ts b/src/languages/redshift/redshift.formatter.ts
index 8d14294b..ca4b32c7 100644
--- a/src/languages/redshift/redshift.formatter.ts
+++ b/src/languages/redshift/redshift.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Formatter from '../../formatter/Formatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './redshift.functions.js';
 import { keywords } from './redshift.keywords.js';
 
@@ -142,42 +140,37 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://docs.aws.amazon.com/redshift/latest/dg/cm_chap_SQLCommandRef.html
-export default class RedshiftFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: ["''-qq"],
-      identTypes: [`""-qq`],
-      identChars: { first: '#' },
-      paramTypes: { numbered: ['$'] },
-      operators: [
-        '^',
-        '%',
-        '@',
-        '|/',
-        '||/',
-        '&',
-        '|',
-        // '#', conflicts with first char of identifier
-        '~',
-        '<<',
-        '>>',
-        '||',
-        '::',
-      ],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      alwaysDenseOperators: ['::'],
-      onelineClauses,
-    };
-  }
-}
+export const redshift: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: ["''-qq"],
+    identTypes: [`""-qq`],
+    identChars: { first: '#' },
+    paramTypes: { numbered: ['$'] },
+    operators: [
+      '^',
+      '%',
+      '@',
+      '|/',
+      '||/',
+      '&',
+      '|',
+      // '#', conflicts with first char of identifier
+      '~',
+      '<<',
+      '>>',
+      '||',
+      '::',
+    ],
+  },
+  formatOptions: {
+    alwaysDenseOperators: ['::'],
+    onelineClauses,
+  },
+};
diff --git a/src/languages/singlestoredb/singlestoredb.formatter.ts b/src/languages/singlestoredb/singlestoredb.formatter.ts
index 1c4bd310..447251e3 100644
--- a/src/languages/singlestoredb/singlestoredb.formatter.ts
+++ b/src/languages/singlestoredb/singlestoredb.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { EOF_TOKEN, isToken, Token, TokenType } from '../../lexer/token.js';
 import { keywords } from './singlestoredb.keywords.js';
 import { functions } from './singlestoredb.functions.js';
@@ -234,40 +232,35 @@ const reservedPhrases = expandPhrases([
   '{ROWS | RANGE} BETWEEN',
 ]);
 
-export default class SingleStoreDbFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      // TODO: support _binary"some string" prefix
-      stringTypes: [
-        '""-qq-bs',
-        "''-qq-bs",
-        { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
-      ],
-      identTypes: ['``'],
-      identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
-      variableTypes: [
-        { regex: '@@?[A-Za-z0-9_$]+' },
-        { quote: '``', prefixes: ['@'], requirePrefix: true },
-      ],
-      lineCommentTypes: ['--', '#'],
-      operators: [':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||'],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const singlestoredb: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    // TODO: support _binary"some string" prefix
+    stringTypes: [
+      '""-qq-bs',
+      "''-qq-bs",
+      { quote: "''-raw", prefixes: ['B', 'X'], requirePrefix: true },
+    ],
+    identTypes: ['``'],
+    identChars: { first: '$', rest: '$', allowFirstCharNumber: true },
+    variableTypes: [
+      { regex: '@@?[A-Za-z0-9_$]+' },
+      { quote: '``', prefixes: ['@'], requirePrefix: true },
+    ],
+    lineCommentTypes: ['--', '#'],
+    operators: [':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||'],
+    postProcess,
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]) {
   return tokens.map((token, i) => {
diff --git a/src/languages/snowflake/snowflake.formatter.ts b/src/languages/snowflake/snowflake.formatter.ts
index b0dbaaf8..cdc599b4 100644
--- a/src/languages/snowflake/snowflake.formatter.ts
+++ b/src/languages/snowflake/snowflake.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Formatter from '../../formatter/Formatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './snowflake.functions.js';
 import { keywords } from './snowflake.keywords.js';
 
@@ -296,46 +294,41 @@ const reservedPhrases = expandPhrases([
   'ON {UPDATE | DELETE} [SET NULL | SET DEFAULT]',
 ]);
 
-export default class SnowflakeFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: ['$$', `''-qq-bs`],
-      identTypes: ['""-qq'],
-      variableTypes: [
-        // for accessing columns at certain positons in the table
-        { regex: '[$][1-9]\\d*' },
-        // identifier style syntax
-        { regex: '[$][_a-zA-Z][_a-zA-Z0-9$]*' },
-      ],
-      extraParens: ['[]'],
-      identChars: { rest: '$' },
-      lineCommentTypes: ['--', '//'],
-      operators: [
-        // Modulo
-        '%',
-        // Type cast
-        '::',
-        // String concat
-        '||',
-        // Get Path
-        ':',
-        // Generators: https://docs.snowflake.com/en/sql-reference/functions/generator.html#generator
-        '=>',
-      ],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      alwaysDenseOperators: [':', '::'],
-      onelineClauses,
-    };
-  }
-}
+export const snowflake: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: ['$$', `''-qq-bs`],
+    identTypes: ['""-qq'],
+    variableTypes: [
+      // for accessing columns at certain positons in the table
+      { regex: '[$][1-9]\\d*' },
+      // identifier style syntax
+      { regex: '[$][_a-zA-Z][_a-zA-Z0-9$]*' },
+    ],
+    extraParens: ['[]'],
+    identChars: { rest: '$' },
+    lineCommentTypes: ['--', '//'],
+    operators: [
+      // Modulo
+      '%',
+      // Type cast
+      '::',
+      // String concat
+      '||',
+      // Get Path
+      ':',
+      // Generators: https://docs.snowflake.com/en/sql-reference/functions/generator.html#generator
+      '=>',
+    ],
+  },
+  formatOptions: {
+    alwaysDenseOperators: [':', '::'],
+    onelineClauses,
+  },
+};
diff --git a/src/languages/spark/spark.formatter.ts b/src/languages/spark/spark.formatter.ts
index 22734370..e68ad948 100644
--- a/src/languages/spark/spark.formatter.ts
+++ b/src/languages/spark/spark.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { EOF_TOKEN, isToken, Token, TokenType } from '../../lexer/token.js';
 import { keywords } from './spark.keywords.js';
 import { functions } from './spark.functions.js';
@@ -120,37 +118,32 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // http://spark.apache.org/docs/latest/sql-programming-guide.html
-export default class SparkFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      supportsXor: true,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      extraParens: ['[]'],
-      stringTypes: [
-        "''-bs",
-        '""-bs',
-        { quote: "''-raw", prefixes: ['R', 'X'], requirePrefix: true },
-        { quote: '""-raw', prefixes: ['R', 'X'], requirePrefix: true },
-      ],
-      identTypes: ['``'],
-      variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
-      operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||', '->'],
-      postProcess,
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const spark: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    supportsXor: true,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    extraParens: ['[]'],
+    stringTypes: [
+      "''-bs",
+      '""-bs',
+      { quote: "''-raw", prefixes: ['R', 'X'], requirePrefix: true },
+      { quote: '""-raw', prefixes: ['R', 'X'], requirePrefix: true },
+    ],
+    identTypes: ['``'],
+    variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
+    operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||', '->'],
+    postProcess,
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
 
 function postProcess(tokens: Token[]) {
   return tokens.map((token, i) => {
diff --git a/src/languages/sql/sql.formatter.ts b/src/languages/sql/sql.formatter.ts
index 51f31ddb..5b4ba2fd 100644
--- a/src/languages/sql/sql.formatter.ts
+++ b/src/languages/sql/sql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './sql.functions.js';
 import { keywords } from './sql.keywords.js';
 
@@ -75,29 +73,24 @@ const reservedPhrases = expandPhrases([
   '{ROWS | RANGE} BETWEEN',
 ]);
 
-export default class SqlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: [
-        { quote: "''-qq-bs", prefixes: ['N', 'U&'] },
-        { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
-      ],
-      identTypes: [`""-qq`, '``'],
-      paramTypes: { positional: true },
-      operators: ['||'],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const sql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: [
+      { quote: "''-qq-bs", prefixes: ['N', 'U&'] },
+      { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
+    ],
+    identTypes: [`""-qq`, '``'],
+    paramTypes: { positional: true },
+    operators: ['||'],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/languages/sqlite/sqlite.formatter.ts b/src/languages/sqlite/sqlite.formatter.ts
index 5c4c9efe..ca7ba517 100644
--- a/src/languages/sqlite/sqlite.formatter.ts
+++ b/src/languages/sqlite/sqlite.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './sqlite.functions.js';
 import { keywords } from './sqlite.keywords.js';
 
@@ -64,32 +62,27 @@ const reservedPhrases = expandPhrases([
   '{ROWS | RANGE | GROUPS} BETWEEN',
 ]);
 
-export default class SqliteFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      stringTypes: [
-        "''-qq",
-        { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
-        // Depending on context SQLite also supports double-quotes for strings,
-        // and single-quotes for identifiers.
-      ],
-      identTypes: [`""-qq`, '``', '[]'],
-      // https://www.sqlite.org/lang_expr.html#parameters
-      paramTypes: { positional: true, numbered: ['?'], named: [':', '@', '$'] },
-      operators: ['%', '~', '&', '|', '<<', '>>', '==', '->', '->>', '||'],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const sqlite: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    stringTypes: [
+      "''-qq",
+      { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
+      // Depending on context SQLite also supports double-quotes for strings,
+      // and single-quotes for identifiers.
+    ],
+    identTypes: [`""-qq`, '``', '[]'],
+    // https://www.sqlite.org/lang_expr.html#parameters
+    paramTypes: { positional: true, numbered: ['?'], named: [':', '@', '$'] },
+    operators: ['%', '~', '&', '|', '<<', '>>', '==', '->', '->>', '||'],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/languages/transactsql/transactsql.formatter.ts b/src/languages/transactsql/transactsql.formatter.ts
index cbda06b6..9bfbf385 100644
--- a/src/languages/transactsql/transactsql.formatter.ts
+++ b/src/languages/transactsql/transactsql.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Formatter from '../../formatter/Formatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './transactsql.functions.js';
 import { keywords } from './transactsql.keywords.js';
 
@@ -221,47 +219,42 @@ const reservedPhrases = expandPhrases([
 ]);
 
 // https://docs.microsoft.com/en-us/sql/t-sql/language-reference?view=sql-server-ver15
-export default class TransactSqlFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      nestedBlockComments: true,
-      stringTypes: [{ quote: "''-qq", prefixes: ['N'] }],
-      identTypes: [`""-qq`, '[]'],
-      identChars: { first: '#@', rest: '#@$' },
-      paramTypes: { named: ['@'], quoted: ['@'] },
-      operators: [
-        '%',
-        '&',
-        '|',
-        '^',
-        '~',
-        '!<',
-        '!>',
-        '+=',
-        '-=',
-        '*=',
-        '/=',
-        '%=',
-        '|=',
-        '&=',
-        '^=',
-        '::',
-      ],
-      // TODO: Support for money constants
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      alwaysDenseOperators: ['::'],
-      onelineClauses,
-    };
-  }
-}
+export const transactsql: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    nestedBlockComments: true,
+    stringTypes: [{ quote: "''-qq", prefixes: ['N'] }],
+    identTypes: [`""-qq`, '[]'],
+    identChars: { first: '#@', rest: '#@$' },
+    paramTypes: { named: ['@'], quoted: ['@'] },
+    operators: [
+      '%',
+      '&',
+      '|',
+      '^',
+      '~',
+      '!<',
+      '!>',
+      '+=',
+      '-=',
+      '*=',
+      '/=',
+      '%=',
+      '|=',
+      '&=',
+      '^=',
+      '::',
+    ],
+    // TODO: Support for money constants
+  },
+  formatOptions: {
+    alwaysDenseOperators: ['::'],
+    onelineClauses,
+  },
+};
diff --git a/src/languages/trino/trino.formatter.ts b/src/languages/trino/trino.formatter.ts
index 3965dd90..61be6684 100644
--- a/src/languages/trino/trino.formatter.ts
+++ b/src/languages/trino/trino.formatter.ts
@@ -1,7 +1,5 @@
+import { DialectOptions } from '../../dialect.js';
 import { expandPhrases } from '../../expandPhrases.js';
-import Formatter from '../../formatter/Formatter.js';
-import { DialectFormatOptions } from '../../formatter/ExpressionFormatter.js';
-import Tokenizer from '../../lexer/Tokenizer.js';
 import { functions } from './trino.functions.js';
 import { keywords } from './trino.keywords.js';
 
@@ -124,47 +122,42 @@ const reservedJoins = expandPhrases([
 
 const reservedPhrases = expandPhrases(['{ROWS | RANGE | GROUPS} BETWEEN']);
 
-export default class TrinoFormatter extends Formatter {
-  tokenizer() {
-    return new Tokenizer({
-      reservedSelect,
-      reservedClauses: [...reservedClauses, ...onelineClauses],
-      reservedSetOperations,
-      reservedJoins,
-      reservedPhrases,
-      reservedKeywords: keywords,
-      reservedFunctionNames: functions,
-      // Trino also supports {- ... -} parenthesis.
-      // The formatting of these currently works out as a result of { and -
-      // not getting a space added in-between.
-      // https://trino.io/docs/current/sql/match-recognize.html#row-pattern-syntax
-      extraParens: ['[]', '{}'],
-      // https://trino.io/docs/current/language/types.html#string
-      // https://trino.io/docs/current/language/types.html#varbinary
-      stringTypes: [
-        { quote: "''-qq", prefixes: ['U&'] },
-        { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
-      ],
-      // https://trino.io/docs/current/language/reserved.html
-      identTypes: ['""-qq'],
-      paramTypes: { positional: true },
-      operators: [
-        '%',
-        '->',
-        ':',
-        '||',
-        // Row pattern syntax
-        '|',
-        '^',
-        '$',
-        // '?', conflicts with positional placeholders
-      ],
-    });
-  }
-
-  formatOptions(): DialectFormatOptions {
-    return {
-      onelineClauses,
-    };
-  }
-}
+export const trino: DialectOptions = {
+  tokenizerOptions: {
+    reservedSelect,
+    reservedClauses: [...reservedClauses, ...onelineClauses],
+    reservedSetOperations,
+    reservedJoins,
+    reservedPhrases,
+    reservedKeywords: keywords,
+    reservedFunctionNames: functions,
+    // Trino also supports {- ... -} parenthesis.
+    // The formatting of these currently works out as a result of { and -
+    // not getting a space added in-between.
+    // https://trino.io/docs/current/sql/match-recognize.html#row-pattern-syntax
+    extraParens: ['[]', '{}'],
+    // https://trino.io/docs/current/language/types.html#string
+    // https://trino.io/docs/current/language/types.html#varbinary
+    stringTypes: [
+      { quote: "''-qq", prefixes: ['U&'] },
+      { quote: "''-raw", prefixes: ['X'], requirePrefix: true },
+    ],
+    // https://trino.io/docs/current/language/reserved.html
+    identTypes: ['""-qq'],
+    paramTypes: { positional: true },
+    operators: [
+      '%',
+      '->',
+      ':',
+      '||',
+      // Row pattern syntax
+      '|',
+      '^',
+      '$',
+      // '?', conflicts with positional placeholders
+    ],
+  },
+  formatOptions: {
+    onelineClauses,
+  },
+};
diff --git a/src/sqlFormatter.ts b/src/sqlFormatter.ts
index 108079db..f9b34d13 100644
--- a/src/sqlFormatter.ts
+++ b/src/sqlFormatter.ts
@@ -1,46 +1,52 @@
-import BigQueryFormatter from './languages/bigquery/bigquery.formatter.js';
-import Db2Formatter from './languages/db2/db2.formatter.js';
-import HiveFormatter from './languages/hive/hive.formatter.js';
-import MariaDbFormatter from './languages/mariadb/mariadb.formatter.js';
-import MySqlFormatter from './languages/mysql/mysql.formatter.js';
-import N1qlFormatter from './languages/n1ql/n1ql.formatter.js';
-import PlSqlFormatter from './languages/plsql/plsql.formatter.js';
-import PostgreSqlFormatter from './languages/postgresql/postgresql.formatter.js';
-import RedshiftFormatter from './languages/redshift/redshift.formatter.js';
-import SparkFormatter from './languages/spark/spark.formatter.js';
-import SqliteFormatter from './languages/sqlite/sqlite.formatter.js';
-import SqlFormatter from './languages/sql/sql.formatter.js';
-import TrinoFormatter from './languages/trino/trino.formatter.js';
-import TransactSqlFormatter from './languages/transactsql/transactsql.formatter.js';
-import SingleStoreDbFormatter from './languages/singlestoredb/singlestoredb.formatter.js';
-import SnowflakeFormatter from './languages/snowflake/snowflake.formatter.js';
+import { bigquery } from './languages/bigquery/bigquery.formatter.js';
+import { db2 } from './languages/db2/db2.formatter.js';
+import { hive } from './languages/hive/hive.formatter.js';
+import { mariadb } from './languages/mariadb/mariadb.formatter.js';
+import { mysql } from './languages/mysql/mysql.formatter.js';
+import { n1ql } from './languages/n1ql/n1ql.formatter.js';
+import { plsql } from './languages/plsql/plsql.formatter.js';
+import { postgresql } from './languages/postgresql/postgresql.formatter.js';
+import { redshift } from './languages/redshift/redshift.formatter.js';
+import { spark } from './languages/spark/spark.formatter.js';
+import { sqlite } from './languages/sqlite/sqlite.formatter.js';
+import { sql } from './languages/sql/sql.formatter.js';
+import { trino } from './languages/trino/trino.formatter.js';
+import { transactsql } from './languages/transactsql/transactsql.formatter.js';
+import { singlestoredb } from './languages/singlestoredb/singlestoredb.formatter.js';
+import { snowflake } from './languages/snowflake/snowflake.formatter.js';
 
 import { FormatOptions } from './FormatOptions.js';
 import { ParamItems } from './formatter/Params.js';
+import { createDialect, DialectOptions } from './dialect.js';
+import Formatter from './formatter/Formatter.js';
 
 export const formatters = {
-  bigquery: BigQueryFormatter,
-  db2: Db2Formatter,
-  hive: HiveFormatter,
-  mariadb: MariaDbFormatter,
-  mysql: MySqlFormatter,
-  n1ql: N1qlFormatter,
-  plsql: PlSqlFormatter,
-  postgresql: PostgreSqlFormatter,
-  redshift: RedshiftFormatter,
-  singlestoredb: SingleStoreDbFormatter,
-  snowflake: SnowflakeFormatter,
-  spark: SparkFormatter,
-  sql: SqlFormatter,
-  sqlite: SqliteFormatter,
-  transactsql: TransactSqlFormatter,
-  trino: TrinoFormatter,
-  tsql: TransactSqlFormatter, // alias for transactsql
+  bigquery,
+  db2,
+  hive,
+  mariadb,
+  mysql,
+  n1ql,
+  plsql,
+  postgresql,
+  redshift,
+  singlestoredb,
+  snowflake,
+  spark,
+  sql,
+  sqlite,
+  transactsql,
+  trino,
+  tsql: transactsql, // alias for transactsql
 };
 export type SqlLanguage = keyof typeof formatters;
 export const supportedDialects = Object.keys(formatters);
 
-const defaultOptions: FormatOptions = {
+export interface FormatOptionsWithLanguage extends FormatOptions {
+  language: SqlLanguage | DialectOptions;
+}
+
+const defaultOptions: FormatOptionsWithLanguage = {
   language: 'sql',
   tabWidth: 2,
   useTabs: false,
@@ -59,10 +65,10 @@ const defaultOptions: FormatOptions = {
  * Format whitespace in a query to make it easier to read.
  *
  * @param {string} query - input SQL query string
- * @param {FormatOptions} cfg Configuration options (see docs in README)
+ * @param {Partial<FormatOptionsWithLanguage>} cfg Configuration options (see docs in README)
  * @return {string} formatted query
  */
-export const format = (query: string, cfg: Partial<FormatOptions> = {}): string => {
+export const format = (query: string, cfg: Partial<FormatOptionsWithLanguage> = {}): string => {
   if (typeof query !== 'string') {
     throw new Error('Invalid query argument. Expected string, instead got ' + typeof query);
   }
@@ -72,15 +78,15 @@ export const format = (query: string, cfg: Partial<FormatOptions> = {}): string
     ...cfg,
   });
 
-  const FormatterCls =
+  const dialectOptions: DialectOptions =
     typeof options.language === 'string' ? formatters[options.language] : options.language;
 
-  return new FormatterCls(options).format(query);
+  return new Formatter(createDialect(dialectOptions), options).format(query);
 };
 
 export class ConfigError extends Error {}
 
-function validateConfig(cfg: FormatOptions): FormatOptions {
+function validateConfig(cfg: FormatOptionsWithLanguage): FormatOptionsWithLanguage {
   if (typeof cfg.language === 'string' && !supportedDialects.includes(cfg.language)) {
     throw new ConfigError(`Unsupported SQL dialect: ${cfg.language}`);
   }
diff --git a/src/utils.ts b/src/utils.ts
index 546cebea..25eef752 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -30,11 +30,3 @@ export const isMultiline = (text: string): boolean => /\n/.test(text);
 //   type Foo = { foo?: string, bar: number };
 //
 export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
-
-// Caches return value of a function in specified field in class object/function
-export const cacheInClassField = <T>(cls: any, fieldName: string, fn: () => T): T => {
-  if (!cls[fieldName]) {
-    cls[fieldName] = fn();
-  }
-  return cls[fieldName];
-};


Test patch:
diff --git a/test/sqlFormatter.test.ts b/test/sqlFormatter.test.ts
index 8492136c..a6e5ddd8 100644
--- a/test/sqlFormatter.test.ts
+++ b/test/sqlFormatter.test.ts
@@ -1,7 +1,7 @@
 import dedent from 'dedent-js';
 
 import { format, SqlLanguage } from '../src/sqlFormatter.js';
-import SqliteFormatter from '../src/languages/sqlite/sqlite.formatter.js';
+import { sqlite } from '../src/languages/sqlite/sqlite.formatter.js';
 
 describe('sqlFormatter', () => {
   it('throws error when unsupported language parameter specified', () => {
@@ -56,8 +56,8 @@ describe('sqlFormatter', () => {
     }).toThrow('aliasAs config is no more supported.');
   });
 
-  it('allows passing Formatter class as a language parameter', () => {
-    expect(format('SELECT [foo], `bar`;', { language: SqliteFormatter })).toBe(dedent`
+  it('allows passing Dialect config object as a language parameter', () => {
+    expect(format('SELECT [foo], `bar`;', { language: sqlite })).toBe(dedent`
       SELECT
         [foo],
         \`bar\`;