Nathan Friend
Nathan Friend

Reputation: 12804

How can I create an object with strong-typed properties that also disallows non-existent properties from being referenced?

Title is a bit weird, but hopefully I can explain by example.

I have an object that holds a bunch of SQL queries (defined at compile-time) that looks like this:

const queries = {
    getProductById: {
        params: { id: 'number' },
        sql: `select * from product where id = :id`
    },
    getCustomerById: {
        params: { id: 'number' },
        sql: `select * from customer where id = :id`
    }

    // ... etc ...
};

export { queries };

When I need to use one of these queries in another file, I can import the queries object and reference the query by key, which is type-checked by the TypeScript compiler:

// compiles without issues
db.executeQuery(queries.getProductById, { id: 42 });

// compiler error, because "nonexistentQuery" isn't defined
db.executeQuery(queries.nonexistentQuery, { id: 7 });

Now I'd like to be able to define an interface that will add type safety to my queries variable (in the first example above). I first approached this by creating an interface with an index signature:

interface IQueryList {
    [queryName: string]: {
        params?: { [paramName: string]: string };
        sql: string;
    };
}

However, when I add this type annotation to my queries variable, I lose type safety when referencing a specific query:

const queries: IQueryList = {
    getProductById: {
        params: { id: 'number' },
        sql: `select * from product where id = :id`
    }
};

// no compiler error, because "queries" has an index signature
db.executeQuery(queries.nonexistentQuery, { id: 12 });

Is there a way to get the best of both worlds - type safety when defining my query, and safety against referencing a query that isn't defined on the queries object?

I could accomplish this by tagging each query with its own type annotation, like this:

interface IQuery {
    params?: { [paramName: string]: string };
    sql: string;
}

const getProductQuery: IQuery = {
    params: { id: 'number' },
    sql: `select * from product where id = :id`
};

const queries = {
    getProductQuery
};

// compiler error, because "nonexistentQuery" doesn't exist
db.executeQuery(queries.nonexistentQuery, { id: 12 });

but I'd prefer to avoid this since each query would need to be tagged individually. In addition, nothing would prevent an incorrectly-formed object from being included in the final queries object.

Upvotes: 1

Views: 48

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249556

You can use a helper function when you define to enforce the constraint, but keep correct checking when using the queries object:

function defineQueries<T extends IQueryList>(q: T) : T {
    return q;
}

const queries =defineQueries({
    getProductById: {
        params: { id: 'number' },
        sql: `select * from product where id = :id`
    }
});

queries.getProductById; //ok
queries.notAQuery // error

Upvotes: 3

Related Questions