1

I have the following code for a query building I'm writing for fun:

export enum ColFlag {
  PrimaryKey = 1,
  NotNull,
  Unique,
  Binary,
  Unsigned,
  ZF,
  AutoIncrement,
  GeneratedColumn
}

export interface IColumn {
  name: string;
  alias?: string;
  flags?: ColFlag[]
}

export class Column implements IColumn {
  name: string = '';
  alias?: string;
  flags?: ColFlag[] = [];

  constructor(init?: Partial<IColumn>) {
    if (init) {
      if (init.name) this.name = init.name;
      if (init.alias) this.alias = init.alias;
      if (init.flags) this.flags = init.flags;
    }
  }
}

export interface ITable {
  name: string;
  columns: IColumn[];
}

export class Table implements ITable {
  name: string = '';
  columns: Column[] = [];

  constructor(init?: Partial<ITable>) {
    if (init) {
      if (init.name) this.name = init.name;
      if (init.columns) this.columns = init.columns.map(c => new Column(c));
    }
  }
}

export class Query<T extends Table> {
  table: T;
  cols: Column[] = [];

  constructor(table: T) {
    this.table = table;
  }


  select(cols: (T['columns'][number]['name'])[]): this {
    // do stuff
    return this;
  }
}

Which when used looks like:

const testTable: ITable = {
  name: 'test_table',
  columns: [
    {
      name: 'id',
      flags: [ColFlag.AutoIncrement, ColFlag.NotNull, ColFlag.PrimaryKey]
    },
    {
      name: 'value'
    }
  ]
}

const query = new Query(testTable).select(['value']);

My current solution transpiles just fine, but it doesn't provide any meaningful intellisense. Incorrect values will also be accecpted by the transpilers. Is there any to dynamically determine the string values of the objects within the columns array to act as string literals and catch build-time errors?

1 Answer 1

2

I'm not sure how in-depth to go here, but the basic issue is that both you and the compiler are widening types to the point where the information you care about (i.e., the string literal values of the name field of the columns array) is lost.

First, you: by annotating testTable as ITable, you're telling the compiler to completely forget anything more specific about the actual value assigned. The compiler will dutifully check that the value is assignable to ITable, and then from there on out, the value of testTable is known only to be an ITable. Any particular column names have been widened to string. So the easiest thing to do here: don't annotate the type. Instead, let the compiler infer a narrower type and then pass to it new Query() later. If testTable is a bad ITable, it will be caught when you call new Query(). So you're not losing any type safety here.

Second, the compiler: generally the compiler sees string-valued properties and assumes that you want to treat them as string, not as whatever you've initialized them to. So if I write let a = {name: "x"}, this is inferred as {name: string}. If it were inferred as {name: "x"}, many people would be unhappy when a.name = "y" fails with a compiler error. In your case, you never want to change the name value, so we need to convince the compiler not to widen it. There are several ways to do this; a reasonably straightforward one is to use const assertions:

const testTable = {
    name: 'test_table',
    columns: [
        {
            name: 'id' as const,
            flags: [ColFlag.AutoIncrement, ColFlag.NotNull, ColFlag.PrimaryKey]
        },
        {
            name: 'value' as const
        }
    ]
}

So, you'll see that testTable is not annotated, and the name fields of the columns arrays are const-asserted. If we inspect the type of testTable, we'll see this:

/* const testTable: {
    name: string;
    columns: ({
        name: "id";
        flags: ColFlag[];
    } | {
        name: "value";
        flags?: undefined;
    })[];
} */

That is hopefully specific enough to meet your needs. Let's try it:

const query = new Query(testTable).select(["value"]); // okay

const badQuery = new Query(testTable).select(["valve"]); // error!
//  ----------------------------------------> ~~~~~~~
// Type 'string' is not assignable to type '"id" | "value"'.(2322)

Looks good. You'll find that IntelliSense should prompt you to enter "id" or "value".


Okay, hope that helps; good luck!

Link to code

Sign up to request clarification or add additional context in comments.

5 Comments

Yes thats exactly what I was looking for! With removing the ITable type annotation from our object, is there any way to preserve intellisense when building that object out?
You might make a generic helper function like const asITable = <T extends ITable>(t: T)=>t and call const testTable = asITable({...}); That should prompt you inside of the argument to asITable without widening.
Like this
With a helper function, you can also avoid the const assertion
Is there any reading material or topics that I could research to learn more about the patterns all described within this post? Thanks for all the help!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.