Home > Back-end >  TypeScript Generic uses default type instead of given type
TypeScript Generic uses default type instead of given type

Time:10-14

I'm writing the implementation of an insert post use case. I'm studying the Clean Architecture and I want to create an blog API, where you can create, read, update and delete posts.

The Clean Architecture principles says you have to isolate your application into layers, where each layer is responsible to abstract your code as much as possible. I divided my code into three main layers: core (the most "immutable" abstractions, where I define the data models and the use cases), app (where I wrote the main application code, like validations and other stuff) and resources (the place for the less abstract code, like database connection, adapters, etc).

The general data was abstracted into the core layer, since it's the most fundamental information of my API. But the general data (most specifically id, created_at, updated_at, is_deleted fields) was abstracted into the app layer, because it's database dependent - the id field can be both a number (for general SQL databases) or a string (for MongoDB, for example). Then, i just join the two abstractions into one single model to create the "definitive" application models.

But there's a problem with this: the use cases abstractions uses the id data to define its implementation rules. By example, you have the InsertPost use case, where the return data is the id of the new created post. Since the id is only defined at the app layer, and Clean Architecture don't allow you to request data from more external layers, how can this abstraction know what type of id he's returning? The solution I found is to abstract the id type into the two possible types (number or string), and only provide the definitive type at the app layer.

This is the described InsertPost use case:

// ----- IMPORTS -----
type Id = number | string;

interface Post<IdType extends Id = Id> {
    title: string
    description: string
    body: string
    author_id: IdType
}

interface SuccessfulResponse<T> {
    results: Array<T>
}
// -------------------

export namespace InsertPost { 
    export type Params<IdParam extends Id = Id> = Post<IdParam> 
} 
 
export interface InsertPost<IdType extends Id> { 
    insert: (params: InsertPost.Params) => Promise<SuccessfulResponse<IdType>>
}

I wrote the Params type from the InsertPost namespace to have a Generic Type that only accepts types derived from the Id type. The IdType should also have an default type when no type is given. My problem is that TypeScript is using the default Id type no matter if a valid alternative type was provided or not.

This is my implementation of the InsertPost use case:

// ----- IMPORTS -----
type Id = number | string;

interface Post<IdType extends Id = Id> {
    title: string
    description: string
    body: string
    author_id: IdType
}

interface SuccessfulResponse<T> {
    results: Array<T>
}

namespace DefaultData {
    export type Id = number
    export type IsDeleted = boolean
    export type CreatedAt = Date
    export type UpdatedAt = Date
}

interface DefaultData {
    id: DefaultData.Id
    is_deleted: DefaultData.IsDeleted
    created_at: DefaultData.CreatedAt
    updated_at: DefaultData.UpdatedAt
}

interface InsertPostRepository  {
    insertPost: (user: Post) => Promise<DefaultData.Id>
}

function successfulResponseDataFormatter<T>(data: Array<T> | T): SuccessfulResponse<T> {
    return {
        results: Array.isArray(data) ? data : [data]
    };
}

namespace InsertPost {
    export type Params<IdParam extends Id = Id> = Post<IdParam>
}

interface InsertPost<IdType extends Id> {
    insert: (params: InsertPost.Params) => Promise<SuccessfulResponse<IdType>>
}
// -------------------

export class InsertPostService implements InsertPost<DefaultData.Id> {
    constructor(private readonly insertPostRepository: InsertPostRepository) {}

    async insert(params: InsertPost.Params<DefaultData.Id>): Promise<SuccessfulResponse<DefaultData.Id>> { // TypeScript throws an error at this line
        const { title, description, body, author_id } = params;

        // Validation functions here

        const id = await this.insertPostRepository.insertPost({
            title,
            description,
            body,
            author_id
        });

        return successfulResponseDataFormatter(id);
    }
}

(Here's a link to this code)

TypeScript throws the following errors:

Property 'insert' in type 'InsertPostService' is not assignable to the same property in base type 'InsertPost'.

Type '(params: Params) => Promise<SuccessfulResponse>' is not assignable to type '(params: Params) => Promise<SuccessfulResponse>'.

Types of parameters 'params' and 'params' are incompatible.

Type 'Params' is not assignable to type 'Params'.

Type 'Id' is not assignable to type 'number'.

Type 'string' is not assignable to type 'number'.

From what I've tested, this only happens when I export the type from inside a namespace (unfortunately, I need to use it). I really can't see where I'm going wrong; is there something I'm missing to code, or is this a TypeScript bug?

CodePudding user response:

As written in your example, an InsertPost<XXX> needs to have an insert method whose argument is of type InsertPost.Params, which evaluates to Post<Id>; note how this is independent of XXX. But your InsertPostService has an insert method whose argument is of type InsertPost.Params<DefaultData.Id>, which evaluates to Post<DefaultData.Id>. This is not the same type as Post<Id>, and so you get a compiler error. It's possible that there are some technical issues surrounding parameter bivariance but that's the basic problem you're having.

Presumably you want InsertPost<XXX> to have an insert method whose argument depends on XXX. The most plausible candidate I can think of given your other code is that it should be of type InsertPost.Params<XXX>:

interface InsertPost<IdType extends Id> {
    insert: (params: InsertPost.Params<IdType>) => Promise<SuccessfulResponse<IdType>>
    // -----------------------------> ^^^^^^^^ <--- added this
}

Once I do that your code starts to compile, since your InsertPostService implements InsertPost<DefaultData.Id>. It's always possible you could run into other issues down the line, but this is at least a reasonable first step.

Playground link to code

  • Related