Repository navigation
Replies: 12 comments 12 replies
|
Q4. "What breaks", we had an issue that wasn't completely fixed where the order in which properties are added to the schema differs. A fix based on the computer locale was added but it was lacking a consistent locale. Might be a quick win to add it here |
|
Before going forward, I want to point out some potential problems we will encounter with namespace augmentation. new(better explained): https://youtu.be/l9UE2DNZ4vc Repo: https://github.com/unrevised6419/_test-ts-namespace-augment Note: Will write another comment later regarding the actual discussion topic. |
|
I'll try to be as thorough as possible. Note that we haven't actually deployed a finished CMS yet; we're still in development, but we're working with typescript exclusively, including as strict settings as we can manage from the typescript compiler, eslint, etc. Q1: We import the Also within Q2: We don't allow globalThis.strapi = {
documents: () => ({ findOne: mockFindOne }),
} as unknown as Core.Strapi;This does bring up a much larger issue that I want to make sure isn't missed: testing in typescript against a real Strapi instance was a real pain to get set up. Since Strapi's runtime loaders all expect compiled javascript, we needed to create our own loaders to be able to load our actual typescript configuration so that we could test against our actual code, not have our tests rely on a build step, and link test coverage back correctly. We've had to build custom typescript loaders that dynamically import typescript files and hand their exports to Strapi's registries. These imports are all typed Q3: We have the generated types included in our typescript project, so that they are used to typecheck actual production and test code, but only the parts that are actually used. We haven't run into any issues with the types, aside from the need mentioned above to narrow down the Q4: We commit generated types. All of our content type edits have been done through the admin interface so far, so this pattern has worked for us without issue. If we do start running into any issues, we'll probably update our typecheck script to regenerate types before running Q5: Q6: N/A Q7: Honestly, we haven't had that many issues with using strict typing with Strapi for production code. It would be great if types were a little more discoverable; I've been leaning heavily on LLMs to help discover the right types to use. The bigger gap that I see is on the testing side. Production code seems to support strict typescript without much issue, but testing using typescript requires quite a bit of custom setup. |
|
Follow-up on the questions, as promised. I compile with the strictest settings I can, and most of the pain comes from two compiler flags plus a few gaps in build and tooling. Q2. Where we fall back
Q3. Document Service / @grandivory's Q4. Generated types The output location is fixed. It would also help to generate a UID map that works as both a value and a type. Today I keep one by hand: import type { UID } from '@strapi/strapi';
export const ContentType = {
Invoice: 'api::invoice.invoice',
Payment: 'api::payment.payment',
User: 'plugin::users-permissions.user',
} satisfies Record<string, UID.ContentType>;
export declare namespace ContentType {
export type Invoice = typeof ContentType.Invoice;
export type Payment = typeof ContentType.Payment;
export type User = typeof ContentType.User;
}
strapi.documents(ContentType.Invoice); // value: no string literals scattered around
service(uid: ContentType.Invoice): InvoiceService; // type: usable in overloads and generics
Q6. Plugins and providers
Q6. GraphQL plugin Instead of extending
With Pothos, a resolver's parent, args and context are inferred from the builder, and plugins such as scope-auth and dataloader are typed end to end. The only bridge into the current plugin we see is to print the schema to SDL, extract a resolver map, and pass both through If the plugin ever moves off Nexus, or accepts a Q7. What good looks like
|
|
We use the types quite a bit in our Strapi NextJS Monorepo Starter. It worked quite well for us, but we've encountered some challenges along the way. Q1 - Which types and from where: Our own Result<TUID, TParams> wraps Q2 - Escape hatches
Q3 - Document Service typing:
Q4 - Commit vs regenerate: Q5 - tsc time and schema size:
Q7 - What good looks like:
|
|
Regarding incremental |
|
Q7. Distinguish entry Today
I'd propose two levels, the loose one by default and the strict one opt-in. Benefits:
1. Flavor (loose, default, non-breaking) declare const flavor: unique symbol;
export type Flavor<TValue, TName extends string> = TValue & { readonly [flavor]?: TName };
export type ID = Flavor<string | number, 'ID'>;
export type DocumentID = Flavor<string, 'DocumentID'>;
I've done this on a fork branch, with compile-time tests, and typed every id in users-permissions with it: unrevised6419#20. Widening the The limit of a flavor: it can't catch a plain 2. Brand (strict, opt-in) plus runtime guards With the strict opt-in (the same declare const brand: unique symbol;
export type Brand<TValue, TName extends string> = TValue & { readonly [brand]: TName };
type Strict = Strapi.Registries.Settings extends { strictIds: true } ? true : false;
export type ID = Strict extends true ? Brand<number, 'ID'> : Flavor<string | number, 'ID'>;
export type DocumentID = Strict extends true ? Brand<string, 'DocumentID'> : Flavor<string, 'DocumentID'>;A raw string or number is then no longer accepted, so it has to go through a guard at the boundary. Those guards would live in // Entry ids are positive integers (numeric strings in route params)
export const isEntryID = (value: unknown): value is Data.ID =>
(typeof value === 'number' && Number.isSafeInteger(value) && value > 0) ||
(typeof value === 'string' && /^[1-9]\d*$/.test(value));
// Document ids come from cuid2's `createId()`, which always starts with a letter
export const isDocumentID = (value: unknown): value is Data.DocumentID =>
typeof value === 'string' && /^[a-z][0-9a-z]+$/.test(value);
// Throwing variants for controllers: a misplaced id becomes a 400, not a 500
export function assertEntryID(value: unknown, name = 'id'): asserts value is Data.ID { … }
export function assertDocumentID(value: unknown, name = 'documentId'): asserts value is Data.DocumentID { … }Because the default const { id } = ctx.params;
if (isDocumentID(id)) return strapi.documents(uid).findOne({ documentId: id });
assertEntryID(id);
return strapi.db.query(uid).findOne({ where: { id } });Open points:
If the flavor part is welcome, I can open the |
|
Hey @nclsndr! Regarding the Strict Strapi Types PRs. declare global {
namespace Strapi {
namespace Registries {
// ...
}
}
}vs declare module '@strapi/types/registries' {
// ...
} |
First experiment: registered server contractsThanks to everyone who answered so far. Your comments gave us a clear first target: server lookups that lose their types, and an augmentation model that depends on which module TypeScript resolves first. #27807 is our first experiment on that. It is exploratory work. It is a draft, it is not scheduled for a release, and it will not be merged as is. Parts of it may change or be dropped based on what you find. We share it now because type changes only show their problems on real codebases. What it exploresEverything is behind an opt-in in export default {
autogenerate: true,
strictTypes: true,
};Without the flag, types stay the same as the current release. We check this with dedicated fixtures. With the flag:
What it does not coverMany of your points are still not part of this experiment: How to try itFind npx create-strapi-app@<EXPERIMENTAL_VERSION> my-app --typescriptOn an existing app, install the same version of the What we want to know
A short reply with the error and a snippet would be really helpful. AI assisted |
|
Note While I was doing the experiment I did find some things to fix in our code. I will give a second attempt to this. Feedback from trying Type generation
Contracts and lookups
Packaging
Templates
|
|
Thank you for this initiative. TS in strapi 5 is half good half frustrating too ^^. I'm in the middle of a (long and painful) v4 -> v5 migration, so my feedback is not complete since I still have 5 custom local plugins to migrate, but here is my feedback for now. Q1. Which Strapi types do you import, and from which package?
Q2. Where do you fall back to any, as unknown as, or @ts-expect-error? Please share code when possible.
Q3. Document Service: do fields, populate, filters, status and locale type correctly against your schemas? Where do they not?almost. As mentioned in Q1, DS api fails to type media fields, they are always typed as type MyFixedCT = Omit<
Data.ContentType<'api::....'>, 'media'
> & {
media?: Data.ContentType<'plugin::upload.file'>;
};
const doc:MyFixedCT|null = strapi.documents('api::....')
.findOne({ documentId:'xxx', populate: ['media']})
if (doc) {
console.log(doc.media?.folder, doc.media?.url);
}Q4. Do you commit generated types, regenerate them in CI, or both? What breaks?commit, no break. Q5. How long does tsc --noEmit take, and how big is your schema (content types, components, dynamic zones)?2sec. / 7 CT (no components, no dynamic zones) Q6. Plugin authors: what is missing to type a plugin's server side and admin side?still has to investigate this (in the process of migrating project) Q7. What would a good version of this look like for you?no precise idea but generally a better documentation. hope this can help |
|
Proposal: audit each package's public API and types before #27807 turns the types a package already exposes into contracts that apps compile against. Most of those contracts are inferred from the implementation, and they reach apps through the So I'd propose a pass over each package's public API before the feature is released. The pass makes sure the types are aligned with the runtime, correct, and free of things nobody uses. What this looks like in practice I did this for email and upload, as a stack of three PRs:
These are the kinds of problems they found. I'd expect the same in other packages:
Proposal
The email and upload PRs above can serve as the pilot. I'm happy to take other packages next. |
Uh oh!
There was an error while loading. Please reload this page.
We're starting a series of experiments on Strapi's type system, beginning with how the core exposes types to your application code. Before we move anything, we want to look at real use cases.
What we're looking at first
The path types take to reach your app:
@strapi/typesand itsPublicregistries (ContentTypeSchemas,ComponentSchemas,Services,Controllers, ...)strapi ts:generate-types:types/generated/contentTypes.d.tsandtypes/generated/components.d.tsWhat we need from you
Real, reasonably complex use cases and apps. Small demos hide the problems we care about. We're after large schemas, many components and dynamic zones, deep populate, custom fields, plugins, monorepos, and strict compiler settings.
Answer any of these in a comment, keeping their Q# ref. One concrete snippet or link is worth more than a long wish list.
Q1. Which Strapi types do you import, and from which package?
Q2. Where do you fall back to
any,as unknown as, or@ts-expect-error? Please share code when possible.Q3. Document Service: do
fields,populate,filters,statusandlocaletype correctly against your schemas? Where do they not?Q4. Do you commit generated types, regenerate them in CI, or both? What breaks?
Q5. How long does
tsc --noEmittake, and how big is your schema (content types, components, dynamic zones)?Q6. Plugin authors: what is missing to type a plugin's server side and admin side?
Q7. What would a good version of this look like for you?
How we'll work
Two weeks for the first round. We'll post what we try here, including the experiments we
drop and why.
This is a loop, not a spec review. You report what hurts, we ship a fix on a branch, you tell us whether it holds on your codebase. Every PR can publish
@strapi/*packages under theexperimentalnpm dist tag, so you install the change and run your own build. No waiting for a release.That last step is the one we can't do alone. Type changes break in ways that only show up on a real codebase.
We'll post what we try in this thread, including the experiments we drop and why.
No commitment beyond a comment. If you can't share code publicly, say so here and we'll find another way to look at it together.
All reactions